diff --git a/package-lock.json b/package-lock.json index 9cc64fc..0edf4ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3708,6 +3708,7 @@ "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { + "@flybrain/brain": "*", "@types/node": "22.17.0", "tsx": "4.20.3", "typescript": "5.9.2" diff --git a/packages/session-types/package.json b/packages/session-types/package.json index 6a388f4..0164827 100644 --- a/packages/session-types/package.json +++ b/packages/session-types/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc -p tsconfig.json --pretty false" }, "devDependencies": { + "@flybrain/brain": "*", "@types/node": "22.17.0", "tsx": "4.20.3", "typescript": "5.9.2" diff --git a/packages/session-types/src/gameboy.ts b/packages/session-types/src/gameboy.ts index b81cd5f..261e708 100644 --- a/packages/session-types/src/gameboy.ts +++ b/packages/session-types/src/gameboy.ts @@ -486,3 +486,75 @@ export function compositionDeclarationDigest(composition: LegacyGameboyCompositi if (!isDigest(digest)) fail('digest'); return digest; } + +// The decoder configuration digest ------------------------------------------------------------ + +/** The name of the canonical decoder-configuration form (legacy-gameboy-v1 section 12). */ +export const DECODER_CONFIG_FORM = 'gameboy-decoder-config-v1'; + +interface GroupLike { + channels: Record; + decisionMs: number; + holdMs: number; + hysteresis: number; + fatigueGain: number; + fatigueDecay: number; + blockedFatigue: number; + blockedMs: number; +} + +/** Structurally the oracle's `DecoderConfig` (`packages/brain/src/readout/decoder.ts`). */ +export interface DecoderConfigLike { + exclusive?: GroupLike; + macros?: GroupLike; + pulses: { + channel: string; + role: string; + holdMs: number; + cooldownMs: number; + threshold: number; + boot?: { cooldownMs: number; threshold: number }; + throttleGroup?: string; + }[]; + clearLockoutMs: number; +} + +function groupForm(group: GroupLike | undefined): unknown { + if (!group) return null; + return { + // Channel order breaks argmax ties, so it is part of the identity: an array, not a map, + // because canonical JSON sorts object keys. + channels: Object.entries(group.channels).map(([channel, role]) => ({ channel, role })), + decisionMs: group.decisionMs, + holdMs: group.holdMs, + hysteresis: group.hysteresis, + fatigueGain: group.fatigueGain, + fatigueDecay: group.fatigueDecay, + blockedFatigue: group.blockedFatigue, + blockedMs: group.blockedMs, + }; +} + +/** The canonical form `decoderConfigDigest` is taken over. */ +export function decoderConfigForm(config: DecoderConfigLike): unknown { + return { + form: DECODER_CONFIG_FORM, + exclusive: groupForm(config.exclusive), + macros: groupForm(config.macros), + pulses: config.pulses.map((pulse) => ({ + channel: pulse.channel, + role: pulse.role, + holdMs: pulse.holdMs, + cooldownMs: pulse.cooldownMs, + threshold: pulse.threshold, + boot: pulse.boot ? { cooldownMs: pulse.boot.cooldownMs, threshold: pulse.boot.threshold } : null, + throttleGroup: pulse.throttleGroup ?? null, + })), + clearLockoutMs: config.clearLockoutMs, + }; +} + +/** `LegacyGameboyComposition.decoderConfigDigest`: SHA-256 of the canonical form. */ +export function decoderConfigDigest(config: DecoderConfigLike): Digest { + return digestOf(decoderConfigForm(config)); +} diff --git a/packages/session-types/tests/gameboy.test.ts b/packages/session-types/tests/gameboy.test.ts index a38b9e4..b92ebd9 100644 --- a/packages/session-types/tests/gameboy.test.ts +++ b/packages/session-types/tests/gameboy.test.ts @@ -144,3 +144,33 @@ test('a rollback request and the extension methods check what they must', () => validateAgentRollbackResultAgainstScope(result, newEpoch); assert.throws(() => validateAgentRollbackResultAgainstScope(result, { ...newEpoch, step: '4100' })); }); + +test('the decoderConfigDigest vectors are what the TypeScript oracle preset computes', async () => { + const { gameboyDecoderConfig } = await import('@flybrain/brain'); + const file = fixtures.load('gameboy-decoder-config.json') as Record; + const cases = file.cases as Record[]; + assert.deepEqual( + cases.map((item) => item.name), + ['raw', 'macros'], + ); + for (const item of cases) { + const form = gameboy.decoderConfigForm(gameboyDecoderConfig(item.macroChannels as string[])); + assert.deepEqual(form, item.form, `${item.name}: the oracle's form is the Rust twin's`); + assert.equal(canonicalize(form), item.canonical, `${item.name}: canonical bytes`); + assert.equal( + gameboy.decoderConfigDigest(gameboyDecoderConfig(item.macroChannels as string[])), + item.digest, + `${item.name}: digest`, + ); + } + const reversed = [...(cases[1]!.macroChannels as string[])].reverse(); + assert.notEqual( + gameboy.decoderConfigDigest(gameboyDecoderConfig(reversed)), + cases[1]!.digest, + 'channel order is identity', + ); + // The example composition declares the real macros-mode digest and channel set. + const example = legacy().composition.example; + assert.equal(example.decoderConfigDigest, cases[1]!.digest); + assert.deepEqual(example.executor.macroChannels, cases[1]!.macroChannels); +}); diff --git a/services/flysim/crates/fly-session-types/README.md b/services/flysim/crates/fly-session-types/README.md index 8bc46df..6e56558 100644 --- a/services/flysim/crates/fly-session-types/README.md +++ b/services/flysim/crates/fly-session-types/README.md @@ -82,6 +82,7 @@ once and holds both languages to it. | `schema-set.json`, `contract-digest.json` | The canonical schema set and its digest | | `seed-vectors.json` | `seed-derivation-v1` test vectors | | `checkpoint-envelope.json` | One `FLYSESS1` envelope, its layout and the corruptions a reader refuses | +| `gameboy-decoder-config.json` | The `decoderConfigDigest` vectors; written and checked by `flysim`'s `legacy_profile_identity` test (`FLY_UPDATE_FIXTURES=1` rewrites), reproduced by `@flybrain/session-types` from the oracle preset | | `gameboy-legacy.json` | The legacy Game Boy extension set and digest, every registered `SchemaRef`, the legacy profile and its `AssetRef`, the frame clock, an example composition and its digest | The derived files (`schema-set.json`, `contract-digest.json`, the `canonical`/`digest` fields diff --git a/services/flysim/crates/fly-session-types/examples/update_fixtures.rs b/services/flysim/crates/fly-session-types/examples/update_fixtures.rs index 153b4cc..d5b8e5b 100644 --- a/services/flysim/crates/fly-session-types/examples/update_fixtures.rs +++ b/services/flysim/crates/fly-session-types/examples/update_fixtures.rs @@ -34,10 +34,12 @@ pub fn derived() -> Vec<(String, String)> { ] } -/// An example legacy composition. The ROM and decoder digests are placeholders -- the real -/// ones are computed by the composition that runs, and no ROM identity belongs in a fixture -- -/// and the macro channels are a short excerpt of the Pokemon Red set. The compatibility -/// string is today's, byte for byte, because its segments must agree with the declaration. +/// An example legacy composition. The ROM digest is a placeholder -- the real one is computed +/// by the composition that runs, and no ROM identity belongs in a fixture. The macro channels and +/// the decoder digest are the real macros-mode vector of `gameboy-decoder-config.json`, which +/// `flysim`'s `legacy_profile_identity` test computes from `gameboy_decoder_config_with_macros` +/// and the TypeScript test from the oracle preset. The compatibility string is today's, byte for +/// byte, because its segments must agree with the declaration. pub fn example_composition() -> LegacyComposition { let pokered = "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b"; LegacyComposition { @@ -53,12 +55,17 @@ pub fn example_composition() -> LegacyComposition { adapter: "pokered-unique8-v6".to_owned(), symbol_provenance: pokered.to_owned(), mode: "macros".to_owned(), - macro_channels: ["macro_go_objective", "macro_talk", "macro_next", "macro_move_1"] + macro_channels: decoder_vector("macros")["macroChannels"] + .as_array() + .expect("macroChannels") .iter() - .map(|c| (*c).to_owned()) + .map(|c| c.as_str().expect("channel").to_owned()) .collect(), }, - decoder_config_digest: canonical::sha256_hex(b"placeholder: the effective DecoderConfig"), + decoder_config_digest: decoder_vector("macros")["digest"] + .as_str() + .expect("digest") + .to_owned(), environment: gameboy::EnvironmentDeclaration { slots: vec!["best".to_owned()], audio_sample_rate: 48_000, @@ -72,6 +79,17 @@ pub fn example_composition() -> LegacyComposition { } } +/// One case of the (flysim-written) decoder-config vectors. +fn decoder_vector(name: &str) -> Value { + let file = fixtures::load("gameboy-decoder-config.json").expect("gameboy-decoder-config.json"); + fixtures::cases(&file) + .expect("cases") + .iter() + .find(|c| c["name"] == Value::String(name.to_owned())) + .unwrap_or_else(|| panic!("decoder vector {name}")) + .clone() +} + /// The legacy Game Boy extension set, the profile document and its AssetRef, the clock /// vector and an example composition with its digest. fn gameboy_legacy() -> String { diff --git a/services/flysim/crates/fly-session-types/fixtures/gameboy-decoder-config.json b/services/flysim/crates/fly-session-types/fixtures/gameboy-decoder-config.json new file mode 100644 index 0000000..a1a1edc --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/gameboy-decoder-config.json @@ -0,0 +1,335 @@ +{ + "description": "decoderConfigDigest vectors (legacy-gameboy-v1 section 12): the canonical form of gameboy_decoder_config_with_macros / gameboyDecoderConfig for raw mode and the Pokemon Red macro group. Written by FLY_UPDATE_FIXTURES=1 cargo test -p flysim --test legacy_profile_identity; both languages must reproduce every form and digest.", + "cases": [ + { + "name": "raw", + "macroChannels": [], + "form": { + "form": "gameboy-decoder-config-v1", + "exclusive": { + "channels": [ + { + "channel": "up", + "role": "command_0" + }, + { + "channel": "down", + "role": "command_1" + }, + { + "channel": "left", + "role": "command_2" + }, + { + "channel": "right", + "role": "command_3" + } + ], + "decisionMs": 800.0, + "holdMs": 800.0, + "hysteresis": 1.05, + "fatigueGain": 0.08, + "fatigueDecay": 0.8, + "blockedFatigue": 0.35, + "blockedMs": 800.0 + }, + "macros": null, + "pulses": [ + { + "channel": "a", + "role": "command_4", + "holdMs": 85.0, + "cooldownMs": 480.0, + "threshold": 1.0, + "boot": null, + "throttleGroup": null + }, + { + "channel": "b", + "role": "command_5", + "holdMs": 85.0, + "cooldownMs": 480.0, + "threshold": 1.0, + "boot": null, + "throttleGroup": null + }, + { + "channel": "start", + "role": "command_6", + "holdMs": 55.0, + "cooldownMs": 30000.0, + "threshold": 1.35, + "boot": { + "cooldownMs": 2500.0, + "threshold": 1.0 + }, + "throttleGroup": "system" + }, + { + "channel": "select", + "role": "command_7", + "holdMs": 55.0, + "cooldownMs": 30000.0, + "threshold": 1.35, + "boot": { + "cooldownMs": 2500.0, + "threshold": 1.0 + }, + "throttleGroup": "system" + } + ], + "clearLockoutMs": 480.0 + }, + "canonical": "{\"clearLockoutMs\":480,\"exclusive\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"up\",\"role\":\"command_0\"},{\"channel\":\"down\",\"role\":\"command_1\"},{\"channel\":\"left\",\"role\":\"command_2\"},{\"channel\":\"right\",\"role\":\"command_3\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"form\":\"gameboy-decoder-config-v1\",\"macros\":null,\"pulses\":[{\"boot\":null,\"channel\":\"a\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_4\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":null,\"channel\":\"b\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_5\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"start\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_6\",\"threshold\":1.35,\"throttleGroup\":\"system\"},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"select\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_7\",\"threshold\":1.35,\"throttleGroup\":\"system\"}]}", + "digest": "6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812" + }, + { + "name": "macros", + "macroChannels": [ + "macro_go_objective", + "macro_go_out", + "macro_go_warp", + "macro_go_route", + "macro_go_item", + "macro_go_npc", + "macro_go_frontier", + "macro_go_shop", + "macro_go_heal", + "macro_talk", + "macro_menu", + "macro_next", + "macro_yes", + "macro_no", + "macro_close", + "macro_confirm", + "macro_back", + "macro_move_1", + "macro_move_2", + "macro_move_3", + "macro_move_4", + "macro_switch", + "macro_item", + "macro_throw_ball", + "macro_run", + "macro_buy_potion", + "macro_buy_ball", + "macro_buy_antidote", + "macro_buy_repel", + "macro_heal", + "macro_leave" + ], + "form": { + "form": "gameboy-decoder-config-v1", + "exclusive": { + "channels": [ + { + "channel": "up", + "role": "command_0" + }, + { + "channel": "down", + "role": "command_1" + }, + { + "channel": "left", + "role": "command_2" + }, + { + "channel": "right", + "role": "command_3" + } + ], + "decisionMs": 800.0, + "holdMs": 800.0, + "hysteresis": 1.05, + "fatigueGain": 0.08, + "fatigueDecay": 0.8, + "blockedFatigue": 0.35, + "blockedMs": 800.0 + }, + "macros": { + "channels": [ + { + "channel": "macro_go_objective", + "role": "macro_go_objective" + }, + { + "channel": "macro_go_out", + "role": "macro_go_out" + }, + { + "channel": "macro_go_warp", + "role": "macro_go_warp" + }, + { + "channel": "macro_go_route", + "role": "macro_go_route" + }, + { + "channel": "macro_go_item", + "role": "macro_go_item" + }, + { + "channel": "macro_go_npc", + "role": "macro_go_npc" + }, + { + "channel": "macro_go_frontier", + "role": "macro_go_frontier" + }, + { + "channel": "macro_go_shop", + "role": "macro_go_shop" + }, + { + "channel": "macro_go_heal", + "role": "macro_go_heal" + }, + { + "channel": "macro_talk", + "role": "macro_talk" + }, + { + "channel": "macro_menu", + "role": "macro_menu" + }, + { + "channel": "macro_next", + "role": "macro_next" + }, + { + "channel": "macro_yes", + "role": "macro_yes" + }, + { + "channel": "macro_no", + "role": "macro_no" + }, + { + "channel": "macro_close", + "role": "macro_close" + }, + { + "channel": "macro_confirm", + "role": "macro_confirm" + }, + { + "channel": "macro_back", + "role": "macro_back" + }, + { + "channel": "macro_move_1", + "role": "macro_move_1" + }, + { + "channel": "macro_move_2", + "role": "macro_move_2" + }, + { + "channel": "macro_move_3", + "role": "macro_move_3" + }, + { + "channel": "macro_move_4", + "role": "macro_move_4" + }, + { + "channel": "macro_switch", + "role": "macro_switch" + }, + { + "channel": "macro_item", + "role": "macro_item" + }, + { + "channel": "macro_throw_ball", + "role": "macro_throw_ball" + }, + { + "channel": "macro_run", + "role": "macro_run" + }, + { + "channel": "macro_buy_potion", + "role": "macro_buy_potion" + }, + { + "channel": "macro_buy_ball", + "role": "macro_buy_ball" + }, + { + "channel": "macro_buy_antidote", + "role": "macro_buy_antidote" + }, + { + "channel": "macro_buy_repel", + "role": "macro_buy_repel" + }, + { + "channel": "macro_heal", + "role": "macro_heal" + }, + { + "channel": "macro_leave", + "role": "macro_leave" + } + ], + "decisionMs": 800.0, + "holdMs": 800.0, + "hysteresis": 1.05, + "fatigueGain": 0.08, + "fatigueDecay": 0.8, + "blockedFatigue": 0.35, + "blockedMs": 800.0 + }, + "pulses": [ + { + "channel": "a", + "role": "command_4", + "holdMs": 85.0, + "cooldownMs": 480.0, + "threshold": 1.0, + "boot": null, + "throttleGroup": null + }, + { + "channel": "b", + "role": "command_5", + "holdMs": 85.0, + "cooldownMs": 480.0, + "threshold": 1.0, + "boot": null, + "throttleGroup": null + }, + { + "channel": "start", + "role": "command_6", + "holdMs": 55.0, + "cooldownMs": 30000.0, + "threshold": 1.35, + "boot": { + "cooldownMs": 2500.0, + "threshold": 1.0 + }, + "throttleGroup": "system" + }, + { + "channel": "select", + "role": "command_7", + "holdMs": 55.0, + "cooldownMs": 30000.0, + "threshold": 1.35, + "boot": { + "cooldownMs": 2500.0, + "threshold": 1.0 + }, + "throttleGroup": "system" + } + ], + "clearLockoutMs": 480.0 + }, + "canonical": "{\"clearLockoutMs\":480,\"exclusive\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"up\",\"role\":\"command_0\"},{\"channel\":\"down\",\"role\":\"command_1\"},{\"channel\":\"left\",\"role\":\"command_2\"},{\"channel\":\"right\",\"role\":\"command_3\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"form\":\"gameboy-decoder-config-v1\",\"macros\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"macro_go_objective\",\"role\":\"macro_go_objective\"},{\"channel\":\"macro_go_out\",\"role\":\"macro_go_out\"},{\"channel\":\"macro_go_warp\",\"role\":\"macro_go_warp\"},{\"channel\":\"macro_go_route\",\"role\":\"macro_go_route\"},{\"channel\":\"macro_go_item\",\"role\":\"macro_go_item\"},{\"channel\":\"macro_go_npc\",\"role\":\"macro_go_npc\"},{\"channel\":\"macro_go_frontier\",\"role\":\"macro_go_frontier\"},{\"channel\":\"macro_go_shop\",\"role\":\"macro_go_shop\"},{\"channel\":\"macro_go_heal\",\"role\":\"macro_go_heal\"},{\"channel\":\"macro_talk\",\"role\":\"macro_talk\"},{\"channel\":\"macro_menu\",\"role\":\"macro_menu\"},{\"channel\":\"macro_next\",\"role\":\"macro_next\"},{\"channel\":\"macro_yes\",\"role\":\"macro_yes\"},{\"channel\":\"macro_no\",\"role\":\"macro_no\"},{\"channel\":\"macro_close\",\"role\":\"macro_close\"},{\"channel\":\"macro_confirm\",\"role\":\"macro_confirm\"},{\"channel\":\"macro_back\",\"role\":\"macro_back\"},{\"channel\":\"macro_move_1\",\"role\":\"macro_move_1\"},{\"channel\":\"macro_move_2\",\"role\":\"macro_move_2\"},{\"channel\":\"macro_move_3\",\"role\":\"macro_move_3\"},{\"channel\":\"macro_move_4\",\"role\":\"macro_move_4\"},{\"channel\":\"macro_switch\",\"role\":\"macro_switch\"},{\"channel\":\"macro_item\",\"role\":\"macro_item\"},{\"channel\":\"macro_throw_ball\",\"role\":\"macro_throw_ball\"},{\"channel\":\"macro_run\",\"role\":\"macro_run\"},{\"channel\":\"macro_buy_potion\",\"role\":\"macro_buy_potion\"},{\"channel\":\"macro_buy_ball\",\"role\":\"macro_buy_ball\"},{\"channel\":\"macro_buy_antidote\",\"role\":\"macro_buy_antidote\"},{\"channel\":\"macro_buy_repel\",\"role\":\"macro_buy_repel\"},{\"channel\":\"macro_heal\",\"role\":\"macro_heal\"},{\"channel\":\"macro_leave\",\"role\":\"macro_leave\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"pulses\":[{\"boot\":null,\"channel\":\"a\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_4\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":null,\"channel\":\"b\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_5\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"start\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_6\",\"threshold\":1.35,\"throttleGroup\":\"system\"},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"select\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_7\",\"threshold\":1.35,\"throttleGroup\":\"system\"}]}", + "digest": "82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52" + } + ] +} diff --git a/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json b/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json index 7b16fbb..d0023fc 100644 --- a/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json +++ b/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json @@ -1,6 +1,6 @@ { "description": "The legacy Game Boy composition (legacy-gameboy-v1): registered payload schemas with their SchemaRef digests, the one legacy profile document and its AssetRef, the frame clock, and an example composition declaration with its digest.", - "extensionSetDigest": "7bb9578d89b0a35a2914e699aad86d35ac8859d9ad4367632bd1fdfbd3e942ef", + "extensionSetDigest": "a35823ecf607a218b0c9f9e7ec86585d355cb311f8d7a3ada8aa793e1c9fe47c", "extensionSet": { "contract": "fly-session-types/legacy-gameboy", "version": 1, @@ -187,7 +187,7 @@ "name": "decoderConfigDigest", "kind": "Digest", "required": true, - "constraint": "SHA-256 of the canonical JSON of the effective DecoderConfig (TypeScript shape)" + "constraint": "SHA-256 of the canonical gameboy-decoder-config-v1 form of the effective decoder configuration" }, { "name": "environment", @@ -497,12 +497,39 @@ "mode": "macros", "macroChannels": [ "macro_go_objective", + "macro_go_out", + "macro_go_warp", + "macro_go_route", + "macro_go_item", + "macro_go_npc", + "macro_go_frontier", + "macro_go_shop", + "macro_go_heal", "macro_talk", + "macro_menu", "macro_next", - "macro_move_1" + "macro_yes", + "macro_no", + "macro_close", + "macro_confirm", + "macro_back", + "macro_move_1", + "macro_move_2", + "macro_move_3", + "macro_move_4", + "macro_switch", + "macro_item", + "macro_throw_ball", + "macro_run", + "macro_buy_potion", + "macro_buy_ball", + "macro_buy_antidote", + "macro_buy_repel", + "macro_heal", + "macro_leave" ] }, - "decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854", + "decoderConfigDigest": "82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52", "environment": { "extensions": [ "gameboy-slots-v1" @@ -535,7 +562,7 @@ "checkpointFormatOfRecord": "FLYSIM01", "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" }, - "digest": "f0142c7f09a1319453b472cdddbc1855062af5733f89d1fbc0b82c0adb52b0c7", + "digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0", "recipeLines": [ "fly-session/composition-v1", "session=", diff --git a/services/flysim/crates/fly-session-types/src/gameboy.rs b/services/flysim/crates/fly-session-types/src/gameboy.rs index 5e2d630..29e88f8 100644 --- a/services/flysim/crates/fly-session-types/src/gameboy.rs +++ b/services/flysim/crates/fly-session-types/src/gameboy.rs @@ -328,7 +328,7 @@ pub const DECLARATIONS: &[TypeSchema] = &[ req( "decoderConfigDigest", "Digest", - "SHA-256 of the canonical JSON of the effective DecoderConfig (TypeScript shape)", + "SHA-256 of the canonical gameboy-decoder-config-v1 form of the effective decoder configuration", ), req( "environment", diff --git a/services/flysim/crates/flysim/tests/legacy_profile_identity.rs b/services/flysim/crates/flysim/tests/legacy_profile_identity.rs index 14f58cf..e397bbd 100644 --- a/services/flysim/crates/flysim/tests/legacy_profile_identity.rs +++ b/services/flysim/crates/flysim/tests/legacy_profile_identity.rs @@ -9,9 +9,12 @@ use std::sync::Arc; use fly_session_types::gameboy; use fly_session_types::scalar::RationalNs; +use fly_session_types::{canonical, fixtures}; use flybrain_core::agent::{AgentConfig, DEFAULT_WARMUP_MS, GAMEBOY_MS_PER_FRAME, NeuralAgent}; use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTON_BITS, gameboy_decoder_config_with_macros}; +use flybrain_core::decoder::{DecoderConfig, ExclusiveGroup}; +use serde_json::{Value, json}; #[test] fn the_profile_fingerprint_and_versions_are_the_ones_this_build_computes() { @@ -56,3 +59,86 @@ fn the_profile_clock_warmup_and_joypad_are_the_service_defaults() { assert_eq!(*bit, 1 << index, "bit i is GAMEBOY_BUTTONS[i]"); } } + +fn group_form(group: Option<&ExclusiveGroup>) -> Value { + match group { + None => Value::Null, + Some(g) => json!({ + "channels": g.channels.iter() + .map(|(channel, role)| json!({"channel": channel, "role": role})) + .collect::>(), + "decisionMs": g.decision_ms, + "holdMs": g.hold_ms, + "hysteresis": g.hysteresis, + "fatigueGain": g.fatigue_gain, + "fatigueDecay": g.fatigue_decay, + "blockedFatigue": g.blocked_fatigue, + "blockedMs": g.blocked_ms, + }), + } +} + +/// The canonical decoder-configuration form of legacy-gameboy-v1 section 12, built from the +/// Rust twin. `@flybrain/session-types` builds the same value from the TypeScript oracle's +/// preset (`decoderConfigForm`), and both are held to `fixtures/gameboy-decoder-config.json`. +fn decoder_config_form(config: &DecoderConfig) -> Value { + json!({ + "form": "gameboy-decoder-config-v1", + "exclusive": group_form(config.exclusive.as_ref()), + "macros": group_form(config.macros.as_ref()), + "pulses": config.pulses.iter().map(|p| json!({ + "channel": p.channel, + "role": p.role, + "holdMs": p.hold_ms, + "cooldownMs": p.cooldown_ms, + "threshold": p.threshold, + "boot": p.boot.map_or(Value::Null, |b| json!({"cooldownMs": b.cooldown_ms, "threshold": b.threshold})), + "throttleGroup": p.throttle_group.clone().map_or(Value::Null, Value::String), + })).collect::>(), + "clearLockoutMs": config.clear_lockout_ms, + }) +} + +/// The shared `decoderConfigDigest` vectors: raw mode and the Pokemon Red macro group, from +/// `gameboy_decoder_config_with_macros`. `FLY_UPDATE_FIXTURES=1` rewrites the file; otherwise +/// the checked-in values must be exactly what this build computes. +#[test] +fn the_decoder_config_digest_vectors_are_what_the_rust_preset_computes() { + let pokered: Vec<&str> = flybrain_gb::macro_channels("pokemon-red"); + let cases: Vec = [("raw", Vec::new()), ("macros", pokered)] + .into_iter() + .map(|(name, channels)| { + let form = decoder_config_form(&gameboy_decoder_config_with_macros(&channels)); + json!({ + "name": name, + "macroChannels": channels, + "form": form, + "canonical": canonical::canonicalize(&form).expect("canonical"), + "digest": canonical::digest_of(&form).expect("digest"), + }) + }) + .collect(); + let file = json!({ + "description": "decoderConfigDigest vectors (legacy-gameboy-v1 section 12): the canonical form of gameboy_decoder_config_with_macros / gameboyDecoderConfig for raw mode and the Pokemon Red macro group. Written by FLY_UPDATE_FIXTURES=1 cargo test -p flysim --test legacy_profile_identity; both languages must reproduce every form and digest.", + "cases": cases, + }); + let mut text = serde_json::to_string_pretty(&file).expect("json"); + text.push('\n'); + let path = fixtures::dir().join("gameboy-decoder-config.json"); + if std::env::var_os("FLY_UPDATE_FIXTURES").is_some() { + std::fs::write(&path, &text).expect("write the fixture"); + } + let found = std::fs::read_to_string(&path).expect("the checked-in fixture"); + assert_eq!( + found, text, + "gameboy-decoder-config.json is stale; rerun with FLY_UPDATE_FIXTURES=1" + ); + // Channel order is identity: reversing the macro group moves the digest. + let mut reversed: Vec<&str> = flybrain_gb::macro_channels("pokemon-red"); + reversed.reverse(); + let other = canonical::digest_of(&decoder_config_form(&gameboy_decoder_config_with_macros( + &reversed, + ))) + .expect("digest"); + assert_ne!(Some(other.as_str()), cases[1]["digest"].as_str()); +}