True simultaneous multiplayer for the generative world: the transition consumes BOTH agents' actions per step (A=4 planes: dxA,dyA,dxB,dyB), so two participants drive two distinct entities in ONE world and co-render the same frame — not serialized. Paired with wai.world.model's signed action trace (each actor → a canonical slot), the merged log renders the IDENTICAL world on every peer (convergence), with both agents moving at once. - int_world.rs: generalized to step_planes() / decode_planes() — the transition takes an explicit set of action planes (A = plane count), so A=3 (one agent) and A=4 (two) share one path; step_delta/decode (A=3) now wrap them. Single-agent decode unchanged. - tools/wai_int_world2_export.py: trains a two-agent world (two markers, each agent independently controlled), PTQs T+g_s to WHM1, integer reference decode → hashes (float 27.0 / integer 21.4 dB). Reuses the 1-agent tool's WHM1/PTQ/decode helpers. - tests/int_world2.rs (never-skip) + committed vector tests/vectors/int_world2/: decode_planes regenerates the committed co-control clip byte-for-byte (clip 66b5821899323ff5). Rust == Python. - wai-web: WorldModel::stepPlanes (advance by explicit planes) + per-op slot (actor→canonical lane) in replayWorldOps, so each tab drives its own agent. Live (/world-shared, two BroadcastChannel tabs): each tab a distinct actor → a distinct agent; both replay the merged log and converge on the same session hash + byte-exact two-agent world. Playwright-verified: A drove 3 + B drove 2 → identical session 6748220a…, frame 4751a9d4…, 0 errors. Co-Authored-By: Claude Opus 4.8 <[email protected]>
209 lines
9.2 KiB
Rust
209 lines
9.2 KiB
Rust
//! In-browser **byte-exact interactive world model** — zero JS ML runtime.
|
||
//!
|
||
//! The same pure-i64 Rust that renders `wai.world.model`'s integer reference world
|
||
//! (`wai::int_world`) compiles to wasm32 and produces the IDENTICAL frames here: an
|
||
//! action-conditioned next-frame model whose transition + synthesis are integer
|
||
//! convs + integer IGDN. So the page can drive the world with the arrow keys, render
|
||
//! every frame in pure i64, and re-check each frame's hash against the native
|
||
//! reference. And every keystroke is a signed action op whose trace replays to one
|
||
//! portable **session hash** ([`world_session_hash`]) — replay-equivalence, a
|
||
//! generative interactive world you can put in a receipt.
|
||
|
||
use wasm_bindgen::prelude::*;
|
||
|
||
use wai::int_hyper_synth::{decode as synth_decode, IntModel};
|
||
|
||
/// A stateful integer world model: hold the transition `T` + synthesis `g_s`, carry
|
||
/// the latent state `z`, and advance it one action at a time — the live-play path.
|
||
#[wasm_bindgen]
|
||
pub struct WorldModel {
|
||
t: IntModel,
|
||
gs: IntModel,
|
||
c: usize,
|
||
lat: usize,
|
||
z: Vec<i64>,
|
||
frame_px: usize,
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
impl WorldModel {
|
||
/// Load the two WHM1 models (`world_T.bin`, `world_gs.bin`); `c`/`lat` from
|
||
/// `world.json`.
|
||
#[wasm_bindgen(constructor)]
|
||
pub fn new(t_bin: &[u8], gs_bin: &[u8], c: u32, lat: u32) -> Result<WorldModel, JsError> {
|
||
Ok(WorldModel {
|
||
t: IntModel::from_bytes(t_bin).map_err(JsError::new)?,
|
||
gs: IntModel::from_bytes(gs_bin).map_err(JsError::new)?,
|
||
c: c as usize,
|
||
lat: lat as usize,
|
||
z: Vec::new(),
|
||
frame_px: 0,
|
||
})
|
||
}
|
||
|
||
/// Reset the world to an initial latent `z0` (`c·lat·lat` ints at `2^afrac`);
|
||
/// returns the rendered frame 0 (HWC RGB `u8`).
|
||
pub fn reset(&mut self, z0: &[i32]) -> Box<[u8]> {
|
||
self.z = z0.iter().map(|&v| v as i64).collect();
|
||
let (f, h, _) = synth_decode(&self.gs, &self.z, self.c, self.lat, self.lat);
|
||
self.frame_px = h;
|
||
f.into_boxed_slice()
|
||
}
|
||
|
||
/// Advance the integer transition by one `action` (index into the reference
|
||
/// action set: 0 stay, 1 +x, 2 −x, 3 +y, 4 −y) and render — pure i64, byte-exact.
|
||
pub fn step(&mut self, action: u32) -> Box<[u8]> {
|
||
self.z = wai::int_world::step(&self.t, &self.z, action as usize, self.c, self.lat);
|
||
let (f, _, _) = synth_decode(&self.gs, &self.z, self.c, self.lat, self.lat);
|
||
f.into_boxed_slice()
|
||
}
|
||
|
||
/// Advance the transition by raw `(dx, dy)` deltas and render — the shared-world
|
||
/// path (a merged multi-actor log decodes to a delta sequence via
|
||
/// [`replay_world_ops`]).
|
||
#[wasm_bindgen(js_name = stepDelta)]
|
||
pub fn step_delta(&mut self, dx: i32, dy: i32) -> Box<[u8]> {
|
||
self.z = wai::int_world::step_delta(&self.t, &self.z, dx as i64, dy as i64, self.c, self.lat);
|
||
let (f, _, _) = synth_decode(&self.gs, &self.z, self.c, self.lat, self.lat);
|
||
f.into_boxed_slice()
|
||
}
|
||
|
||
/// Advance the transition by an explicit set of action **planes** (length = the
|
||
/// model's `A`; values at `2^afrac`) and render — the multi-agent path, where a
|
||
/// step carries every agent's action (e.g. `[dxA,dyA,dxB,dyB]` for two agents).
|
||
#[wasm_bindgen(js_name = stepPlanes)]
|
||
pub fn step_planes(&mut self, planes: &[i32]) -> Box<[u8]> {
|
||
let q: Vec<i64> = planes.iter().map(|&v| v as i64).collect();
|
||
self.z = wai::int_world::step_planes(&self.t, &self.z, &q, self.c, self.lat);
|
||
let (f, _, _) = synth_decode(&self.gs, &self.z, self.c, self.lat, self.lat);
|
||
f.into_boxed_slice()
|
||
}
|
||
|
||
#[wasm_bindgen(getter, js_name = framePx)]
|
||
pub fn frame_px(&self) -> u32 {
|
||
self.frame_px as u32
|
||
}
|
||
}
|
||
|
||
/// Replay a signed action trace → the portable **session hash** (hex), in the
|
||
/// browser. `actions_json` is `[{lamport,tick,dx,dy,buttons}]`; each is signed with
|
||
/// the actor key derived from `key_seed_hex` (32-byte seed), then replayed under
|
||
/// canonical total order — the same `wai.world.model` replay-equivalence the native
|
||
/// reference computes. Lets the page show that the keystrokes the user just made
|
||
/// hash to one portable, signed session identity.
|
||
#[wasm_bindgen(js_name = worldSessionHash)]
|
||
pub fn world_session_hash(
|
||
model_id_hex: &str, seed: u64, width: u32, height: u32, fps: u32, key_seed_hex: &str, actions_json: &[u8],
|
||
) -> Result<String, JsError> {
|
||
use ed25519_dalek::SigningKey;
|
||
use wai::world_model::{hex32, replay, sign_action, ModelHeader};
|
||
|
||
let model_id = hex32_to_bytes(model_id_hex).ok_or_else(|| JsError::new("bad model_id hex"))?;
|
||
let seed_bytes = hex32_to_bytes(key_seed_hex).ok_or_else(|| JsError::new("bad key seed hex"))?;
|
||
let key = SigningKey::from_bytes(&seed_bytes);
|
||
let header = ModelHeader { model_id, seed, width, height, fps };
|
||
|
||
let acts: serde_json::Value =
|
||
serde_json::from_slice(actions_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||
let mut ops = Vec::new();
|
||
for a in acts.as_array().ok_or_else(|| JsError::new("actions must be an array"))? {
|
||
ops.push(sign_action(
|
||
&key,
|
||
a["lamport"].as_u64().unwrap_or(0),
|
||
a["tick"].as_u64().unwrap_or(0),
|
||
a["dx"].as_i64().unwrap_or(0) as i32,
|
||
a["dy"].as_i64().unwrap_or(0) as i32,
|
||
a["buttons"].as_u64().unwrap_or(0) as u32,
|
||
b"",
|
||
));
|
||
}
|
||
let session = replay(&header, &ops, true).map_err(JsError::new)?;
|
||
Ok(hex32(&session.session_hash))
|
||
}
|
||
|
||
/// Sign one action op for a SHARED world and return its serialized bytes (a
|
||
/// `CausalOp`). Each peer signs with its own actor key (`actor_seed_hex`) and
|
||
/// broadcasts these bytes; peers concatenate everyone's ops into one log and
|
||
/// [`replay_world_ops`] it. The signature binds the action to the actor — an op can
|
||
/// only claim the identity that signed it.
|
||
#[wasm_bindgen(js_name = signWorldAction)]
|
||
pub fn sign_world_action(actor_seed_hex: &str, lamport: u64, tick: u64, dx: i32, dy: i32, buttons: u32) -> Result<Box<[u8]>, JsError> {
|
||
use ed25519_dalek::SigningKey;
|
||
let seed = hex32_to_bytes(actor_seed_hex).ok_or_else(|| JsError::new("bad actor seed hex"))?;
|
||
let key = SigningKey::from_bytes(&seed);
|
||
let op = wai::world_model::sign_action(&key, lamport, tick, dx, dy, buttons, b"");
|
||
Ok(op.to_bytes().into_boxed_slice())
|
||
}
|
||
|
||
/// The result of replaying a merged multi-actor action log: the convergent session
|
||
/// hash + the canonical `(dx, dy)` delta sequence to render.
|
||
#[wasm_bindgen]
|
||
pub struct WorldReplay {
|
||
session_hash: String,
|
||
deltas: Vec<i32>,
|
||
slots: Vec<u32>,
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
impl WorldReplay {
|
||
#[wasm_bindgen(getter, js_name = sessionHash)]
|
||
pub fn session_hash(&self) -> String {
|
||
self.session_hash.clone()
|
||
}
|
||
/// Flattened `[dx0, dy0, dx1, dy1, …]` in canonical total order.
|
||
#[wasm_bindgen(getter)]
|
||
pub fn deltas(&self) -> Box<[i32]> {
|
||
self.deltas.clone().into_boxed_slice()
|
||
}
|
||
/// Per-op agent SLOT (the op's actor's index in canonical actor order) — so a
|
||
/// peer can place each op's `(dx,dy)` in its agent's plane lane for multi-agent
|
||
/// co-control. Same on every peer (the actor set + ordering is canonical).
|
||
#[wasm_bindgen(getter)]
|
||
pub fn slots(&self) -> Box<[u32]> {
|
||
self.slots.clone().into_boxed_slice()
|
||
}
|
||
}
|
||
|
||
/// Replay a MERGED multi-actor action log (`log` = everyone's `CausalOp`s
|
||
/// concatenated, any order) → the convergent session hash + the canonical delta
|
||
/// sequence. Every peer that holds the same op set computes the IDENTICAL result —
|
||
/// replay-equivalence — so rendering the deltas yields the same byte-exact world on
|
||
/// every tab. Signatures are verified.
|
||
#[wasm_bindgen(js_name = replayWorldOps)]
|
||
pub fn replay_world_ops(
|
||
model_id_hex: &str, seed: u64, width: u32, height: u32, fps: u32, log: &[u8],
|
||
) -> Result<WorldReplay, JsError> {
|
||
use wai::world::CausalOp;
|
||
use wai::world_model::{hex32, replay, ModelHeader};
|
||
|
||
let model_id = hex32_to_bytes(model_id_hex).ok_or_else(|| JsError::new("bad model_id hex"))?;
|
||
let ops = if log.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
CausalOp::parse_log(log).map_err(|_| JsError::new("malformed action log"))?
|
||
};
|
||
let header = ModelHeader { model_id, seed, width, height, fps };
|
||
let session = replay(&header, &ops, true).map_err(JsError::new)?;
|
||
let deltas = session.actions.iter().flat_map(|a| [a.dx, a.dy]).collect();
|
||
// Assign each distinct actor a stable slot by canonical actor order, so every
|
||
// peer maps the same op to the same agent lane.
|
||
let mut actors: Vec<[u8; 32]> = session.actions.iter().map(|a| a.actor).collect();
|
||
actors.sort_unstable();
|
||
actors.dedup();
|
||
let slots = session.actions.iter().map(|a| actors.iter().position(|x| x == &a.actor).unwrap() as u32).collect();
|
||
Ok(WorldReplay { session_hash: hex32(&session.session_hash), deltas, slots })
|
||
}
|
||
|
||
fn hex32_to_bytes(s: &str) -> Option<[u8; 32]> {
|
||
let s = s.as_bytes();
|
||
if s.len() != 64 {
|
||
return None;
|
||
}
|
||
let mut o = [0u8; 32];
|
||
for (i, b) in o.iter_mut().enumerate() {
|
||
let hi = (s[i * 2] as char).to_digit(16)?;
|
||
let lo = (s[i * 2 + 1] as char).to_digit(16)?;
|
||
*b = (hi * 16 + lo) as u8;
|
||
}
|
||
Some(o)
|
||
}
|