Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/tools/src/EliteSkillsApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ describe("Elite Skills", () => {
expect(wrapper.find('.elite-panel').exists()).toBe(false);
expect(wrapper.get('.elite-mission-tracker').text()).toContain('Lissah');
expect(wrapper.findAll('.elite-marker')).toHaveLength(1);
await wrapper.get('.elite-marker').trigger('pointerenter');
expect(wrapper.find('.elite-preview').exists()).toBe(true);
await wrapper.setProps({ view: { ...eliteFixtureView(true), mission: null } });
expect(wrapper.find('.elite-preview').exists()).toBe(false);
expect(wrapper.find('.elite-marker').exists()).toBe(false);
expect(wrapper.find('.elite-mission-tracker').exists()).toBe(false);
const missionView = eliteFixtureView(true);
await wrapper.setProps({ view: { ...missionView, mission: { box: missionView.mission!.box, transform: null } } });
expect(wrapper.findAll('.elite-marker')).toHaveLength(0);
Expand Down
1 change: 1 addition & 0 deletions apps/tools/src/EliteSkillsApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ function cannotCapture(location: EliteLocation) { return /impossible to capture/
function toggleTrack(id: number) { void plan.change({ kind: tracked(id) ? "remove" : "track", skillId: id }); }
watch(character, () => { selectedId.value = null; preview.value = null; });
watch(() => props.view.world, (value, previous) => { if (!value && previous) setOpen(false); });
watch(() => props.view.mission, (value, previous) => { if (!value && previous) preview.value = null; });
let choseDefaultProfession = false;
watch(() => party.value.player?.professions, (pair) => {
if (!choseDefaultProfession && pair) { profession.value = pair[0]; choseDefaultProfession = true; }
Expand Down
9 changes: 6 additions & 3 deletions docs/cartography.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,12 @@ coverage, and guidance. The compact control remains visible and explains this
limited mode when opened. Settings remain intact and apply again automatically
after travel to a fully supported area.

The World Map projection and visibility come from one complete native event
context. Unrelated or incomplete events leave the last complete reading intact,
while a validated close event hides the overlay.
The World Map projection comes from a complete native event context.
Unrelated or incomplete events leave the last complete projection intact.
Each presentation read also checks the certified native display flag, which
clears before the close fade starts. Closing withdraws all World Map overlays;
reopening requires another complete projection. Mission Map and Compass
readings reject hidden frames and frames that are being destroyed.

At 18 pixels or more per cell, the map draws individual amber diamonds and
orange actionable markers. At 8–18 pixels it groups the global grid into 4×4
Expand Down
18 changes: 17 additions & 1 deletion src/main/certification/cartography-transform-internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ export function nativeFrameObserver(
local(5), i32Load(certificate.frameId), globalSet(globals.frameId),
local(5), i32Load(certificate.frameState), Uint8Array.of(0x22), uleb(3),
i32(4), Uint8Array.of(0x71, 0x45, 0x45),
local(3), i32(0x200), Uint8Array.of(0x71, 0x45, 0x71), globalSet(globals.visible),
local(3), i32(0x208), Uint8Array.of(0x71, 0x45, 0x71), globalSet(globals.visible),
local(5), f32Load(certificate.frameViewportWidth), globalSet(globals.viewportWidth),
local(5), f32Load(certificate.frameViewportHeight), globalSet(globals.viewportHeight),
local(5), f32Load(certificate.frameScreenLeft), globalSet(globals.left),
Expand Down Expand Up @@ -550,6 +550,22 @@ export function missionMapEventWrapper(
);
}

/** Withdraw the retained World Map frame as soon as native closing begins. */
export function worldMapVisibilityObserver(
globals: Pick<WorldMapGlobals, "visible" | "sequence">,
stateAddress: number,
): Uint8Array {
return concat(
Uint8Array.of(0x00),
globalGet(globals.visible), Uint8Array.of(0x04, 0x40),
i32(stateAddress), i32Load(), i32(0x80000), Uint8Array.of(0x71, 0x45, 0x04, 0x40),
i32(0), globalSet(globals.visible),
globalGet(globals.sequence), i32(1), Uint8Array.of(0x6a), globalSet(globals.sequence),
Uint8Array.of(0x0b),
Uint8Array.of(0x0b, 0x0b),
);
}

/** Observe the dedicated native World Map context after its exact event handler. */
export function worldMapEventWrapper(
dispatcher: number,
Expand Down
12 changes: 11 additions & 1 deletion src/main/certification/pathing-spike-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MISSION_MAP_FRAME_SPIKE_SCALARS,
MISSION_MAP_PROJECTION_SPIKE_SCALARS,
WORLD_MAP_FRAME_SPIKE_SCALARS,
WORLD_MAP_FRAME_SPIKE_GLOBALS,
WORLD_MAP_ANCHOR_SPIKE_GLOBALS,
WORLD_MAP_ANCHOR_SPIKE_SCALARS,
} from "../../shared/cartography-spike.js";
Expand All @@ -38,6 +39,7 @@ import {
type Section,
} from "../core/wasm-binary.js";
import { functionBodySha256, wasmEvidence } from "./wasm-evidence.js";
import { worldMapDisplayStateAddress } from "./world-map-visibility-proof.js";
import {
CARTOGRAPHY_MEMORY_LAYOUTS,
COMPASS_CERTIFICATE,
Expand All @@ -57,6 +59,7 @@ import {
rewriteExactTableSlot,
worldMapAnchorObserver,
worldMapEventWrapper,
worldMapVisibilityObserver,
type CartographyContextGlobals,
type CartographyMemoryLayout,
type CompassGlobals,
Expand All @@ -72,7 +75,7 @@ declare const WebAssembly: {
Module: new (bytes: Uint8Array) => object;
};

export const CARTOGRAPHY_SPIKE_TRANSFORM_ABI = 30;
export const CARTOGRAPHY_SPIKE_TRANSFORM_ABI = 31;
export type CartographyMemoryLayoutId = keyof typeof CARTOGRAPHY_MEMORY_LAYOUTS;

const mutableI32 = () => Uint8Array.of(0x7f, 0x01, 0x41, 0x00, 0x0b);
Expand Down Expand Up @@ -245,6 +248,8 @@ export function transformCartographySpikeWasm(
});
const evidence = wasmEvidence(input) ?? fail("invalid WebAssembly input");
const module = evidence.moduleView();
const worldMapDisplayState = worldMapDisplayStateAddress(evidence)
?? fail("World Map display-state certificate changed");
if (
functionBodySha256(module, COMPASS_CERTIFICATE.renderFunction)
!== COMPASS_CERTIFICATE.renderBodySha256
Expand Down Expand Up @@ -276,6 +281,7 @@ export function transformCartographySpikeWasm(
...WORLD_MAP_FRAME_SPIKE_SCALARS,
];
const functionNames = [
WORLD_MAP_FRAME_SPIKE_GLOBALS.observe,
CARTOGRAPHY_CONTEXT_GLOBALS.observe,
COMPASS_FRAME_SPIKE_GLOBALS.observe,
MISSION_MAP_FRAME_SPIKE_GLOBALS.observe,
Expand Down Expand Up @@ -321,6 +327,7 @@ export function transformCartographySpikeWasm(
const explorationReadWordFunction = firstFunction + 6;
const anchorObserverFunction = firstFunction + 7;
const worldEventWrapperFunction = firstFunction + 8;
const worldVisibilityObserverFunction = firstFunction + 9;

const compassRender = bodies[compassRenderLocal]?.slice()
?? fail("Compass render body is missing");
Expand Down Expand Up @@ -377,6 +384,7 @@ export function transformCartographySpikeWasm(
allocated.context.areaEpoch,
worldCertificate,
),
worldMapVisibilityObserver(allocated.world, worldMapDisplayState),
);
const nextFunctionTypes = [
...functionTypes,
Expand All @@ -389,6 +397,7 @@ export function transformCartographySpikeWasm(
readWordType,
voidType,
worldDispatcherType,
voidType,
];

const contextEntries = [
Expand Down Expand Up @@ -424,6 +433,7 @@ export function transformCartographySpikeWasm(
[WORLD_MAP_FRAME_SPIKE_SCALARS, allocated.firstWorld],
] as const;
const functionPlans = [
[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe, worldVisibilityObserverFunction],
[CARTOGRAPHY_CONTEXT_GLOBALS.observe, contextObserverFunction],
[COMPASS_FRAME_SPIKE_GLOBALS.observe, compassObserverFunction],
[MISSION_MAP_FRAME_SPIKE_GLOBALS.observe, missionObserverFunction],
Expand Down
27 changes: 27 additions & 0 deletions src/main/certification/world-map-visibility-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Certifies the native World Map display flag at the start of its close path.
* The frame can survive the fade, so frame destruction cannot own visibility.
*/
import { readUleb } from "../core/wasm-binary.js";
import { relocationAwareFingerprint } from "./semantic-proof.js";
import { functionBody, signatureEvidence, type WasmEvidence } from "./wasm-evidence.js";

const DISPLAY_STATE_OPERANDS = [220, 232] as const;

export function worldMapDisplayStateAddress(evidence: WasmEvidence): number | null {
const module = evidence.moduleView();
// This owner creates World Map table slot 4152. Its close branch clears
// 0x80000 before starting the native 1 -> 0 opacity animation (function 6834).
const body = functionBody(module, 15_919);
const signature = signatureEvidence(module, 15_919);
if (body.byteLength !== 302 || signature === null
|| signature.params.join() !== "i32,i32" || signature.results.length !== 0
|| relocationAwareFingerprint(body, DISPLAY_STATE_OPERANDS.map((start) => ({
start, end: start + 5, role: "world-map.display-state", addressClass: "mutable-static",
}))) !== "23f59ceb29627fbbe1cddf258d6f1051ebb6690a52f7519603d0ec659890ed16") return null;
const addresses = DISPLAY_STATE_OPERANDS.map((offset) => readUleb(body, { offset }));
const address = addresses[0];
return address !== undefined && address > 0 && address <= 0x7fff_fffc
&& address % 4 === 0 && addresses.every((value) => value === address)
? address : null;
}
6 changes: 6 additions & 0 deletions src/renderer/cartography-spike/frame-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,13 @@ export function createCompassFrameSpikeReader(
export function createWorldMapFrameSpikeReader(
exports: WebAssembly.Exports,
): WorldMapFrameSpikeController | null {
const observe = exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe];
if (typeof observe !== "function") return null;
if (!WORLD_MAP_FRAME_SPIKE_SCALARS.every(
(name) => exports[name] instanceof WebAssembly.Global,
)) return null;
const diagnostics = (): WorldMapFrameSpikeDiagnostic => {
observe();
return Object.freeze({
status: numberGlobal(exports, WORLD_MAP_FRAME_SPIKE_GLOBALS.status),
sequence: numberGlobal(exports, WORLD_MAP_FRAME_SPIKE_GLOBALS.sequence),
Expand All @@ -210,6 +213,9 @@ export function createWorldMapFrameSpikeReader(
return Object.freeze({
diagnostics,
snapshot() {
// The last complete projection may outlive the native close animation.
// Refresh visibility independently of projection events on every read.
try { observe(); } catch { return null; }
const firstSequence = numberGlobal(exports, WORLD_MAP_FRAME_SPIKE_GLOBALS.sequence);
const values = Object.fromEntries(WORLD_MAP_FRAME_SPIKE_SCALARS.map(
(name) => [name, numberGlobal(exports, name)],
Expand Down
1 change: 1 addition & 0 deletions src/shared/cartography-spike.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const MISSION_MAP_PROJECTION_SPIKE_SCALARS = Object.freeze([
]);

export const WORLD_MAP_FRAME_SPIKE_GLOBALS = Object.freeze({
observe: "gwonmac_world_map_frame_spike_observe",
status: "gwonmac_world_map_frame_spike_status",
sequence: "gwonmac_world_map_frame_spike_sequence",
generation: "gwonmac_world_map_frame_spike_generation",
Expand Down
37 changes: 37 additions & 0 deletions tests/client-artifact/world-map-visibility-proof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Certifies close-before-fade visibility and refuses a changed native owner. */
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { worldMapDisplayStateAddress } from "../../src/main/certification/world-map-visibility-proof.js";
import { wasmEvidence } from "../../src/main/certification/wasm-evidence.js";
import { concat, encodeCode, encodeSection, paddedIndex, parseCode, sectionById, splitSections, WASM_HEADER } from "../../src/main/core/wasm-binary.js";

const artifact = process.env.GW_CLIENT_WASM;

test("proves the World Map display flag and rejects mismatched reads, writes, and close behavior", async () => {
assert.ok(artifact, "GW_CLIENT_WASM must name the exact official artifact");
const input = new Uint8Array(await readFile(artifact));
const evidence = wasmEvidence(input);
assert.ok(evidence);
const address = worldMapDisplayStateAddress(evidence);
assert.ok(address);
const module = evidence.moduleView();
const changed = (edit: (body: Uint8Array) => void) => {
const sections = splitSections(input);
const bodies = parseCode(sectionById(sections, 10));
edit(bodies[15_919 - module.functionImportCount]!);
const bytes = concat(WASM_HEADER, ...sections.map(section => encodeSection(
section.id === 10 ? { id: 10, body: encodeCode(bodies) } : section)));
const changedEvidence = wasmEvidence(bytes);
assert.ok(changedEvidence);
return worldMapDisplayStateAddress(changedEvidence);
};
assert.equal(changed(body => body.set(paddedIndex(address + 16), 220)), null);
assert.equal(changed(body => body.set(paddedIndex(address + 16), 232)), null);
assert.equal(changed(body => {
body.set(paddedIndex(address + 16), 220);
body.set(paddedIndex(address + 16), 232);
}), address + 16, "both state operands can relocate together");
assert.equal(changed(body => { body[228] = body[228]! ^ 1; }), null,
"a changed close mask must not certify");
});
64 changes: 64 additions & 0 deletions tests/unit/cartography-visibility-transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { concat, encodeCode, encodeSection, uleb, WASM_HEADER } from "../../src/main/core/wasm-binary.js";
import { encodeName, encodeTypes, nativeFrameObserver, worldMapVisibilityObserver } from "../../src/main/certification/cartography-transform-internals.js";

function observer(body: Uint8Array, types: readonly ("i32" | "f32")[]) {
const section = (id: number, body: Uint8Array) => encodeSection({ id, body });
const bytes = concat(WASM_HEADER,
section(1, encodeTypes([{ params: [], results: [] }])),
section(3, Uint8Array.of(1, 0)),
section(5, Uint8Array.of(1, 0, 1)),
section(6, concat(uleb(types.length), ...types.map(type => type === "i32"
? Uint8Array.of(0x7f, 1, 0x41, 0, 0x0b) : Uint8Array.of(0x7d, 1, 0x43, 0, 0, 0, 0, 0x0b)))),
section(7, concat(uleb(types.length + 2), encodeName("observe"), Uint8Array.of(0, 0),
encodeName("memory"), Uint8Array.of(2, 0),
...types.map((_, i) => concat(encodeName(`g${i}`), Uint8Array.of(3), uleb(i))))),
section(10, encodeCode([body])));
const { exports } = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from(bytes)));
const memory = exports.memory;
const run = exports.observe;
assert.ok(memory instanceof WebAssembly.Memory && typeof run === "function");
const global = (i: number) => { const value = exports[`g${i}`]; assert.ok(value instanceof WebAssembly.Global); return value; };
return { run, global, memory: new DataView(memory.buffer) };
}

test("native World Map closing withdraws a retained projection without a map event", () => {
const probe = observer(worldMapVisibilityObserver({ visible: 0, sequence: 1 }, 64), ["i32", "i32"]);
probe.global(0).value = 1;
probe.global(1).value = 9;
probe.memory.setUint32(64, 0x80004, true);
probe.run();
assert.equal(probe.global(0).value, 1);
assert.equal(probe.global(1).value, 9);
probe.memory.setUint32(64, 4, true);
probe.run();
assert.equal(probe.global(0).value, 0);
assert.equal(probe.global(1).value, 10);
probe.run();
assert.equal(probe.global(1).value, 10, "unchanged hidden state is not a new publication");
probe.memory.setUint32(64, 0x80000, true);
probe.run();
assert.equal(probe.global(0).value, 0, "opening cannot resurrect stale geometry");
});

test("native map frames hide at destruction start, before removal from the frame array", () => {
const certificate = { frameArray: 4, frameCount: 8, frameBytes: 48, frameId: 0, frameHashId: 4,
frameState: 8, frameViewportWidth: 12, frameViewportHeight: 16,
frameScreenLeft: 20, frameScreenBottom: 24, frameScreenRight: 28, frameScreenTop: 32, labelHash: 77 };
const globals = { status: 0, generation: 1, frameId: 2, visible: 3,
viewportWidth: 4, viewportHeight: 5, left: 6, bottom: 7, right: 8, top: 9 };
const probe = observer(nativeFrameObserver(certificate, globals, 10),
["i32", "i32", "i32", "i32", "f32", "f32", "f32", "f32", "f32", "f32", "i32"]);
probe.memory.setUint32(4, 64, true);
probe.memory.setUint32(8, 1, true);
probe.memory.setUint32(64, 128, true);
probe.memory.setUint32(128, 0, true);
probe.memory.setUint32(132, 77, true);
for (const [state, visible] of [[4, 1], [12, 0], [0x204, 0], [0, 0], [4, 1]]) {
probe.memory.setUint32(136, state!, true);
probe.run();
assert.equal(probe.global(0).value, 1);
assert.equal(probe.global(3).value, visible);
}
});
23 changes: 23 additions & 0 deletions tests/unit/compass-frame-spike-observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ test("refuses incomplete, stale, and invalid Mission Map projection state", () =
function worldMapExports(): WebAssembly.Exports {
const values = [1, 12, 5, 200, 1, 1_920, 1_080, 0, 0, 1_920, 1_080, 0, 0, 0, 0, 8_192, 16_384];
return {
[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe]: () => undefined,
...Object.fromEntries(WORLD_MAP_FRAME_SPIKE_SCALARS.map((name, index) => [
name,
scalar(values[index]!, index < 5 || index === 11 ? "i32" : "f32"),
Expand Down Expand Up @@ -253,3 +254,25 @@ test("reads the dedicated World Map context atomically", () => {
invalid[WORLD_MAP_FRAME_SPIKE_GLOBALS.bottomRightX] = scalar(0, "f32");
assert.equal(createWorldMapFrameSpikeReader(invalid)?.snapshot(), null);
});

test("refreshes World Map visibility even when no new projection event arrives", () => {
const exports = worldMapExports();
const visible = exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.visible];
assert.ok(visible instanceof WebAssembly.Global);
let showing = true;
exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe] = () => { if (!showing) visible.value = 0; };
const reader = createWorldMapFrameSpikeReader(exports);
assert.ok(reader);
assert.ok(reader.snapshot());
showing = false;
assert.equal(reader.snapshot(), null);
assert.equal(reader.diagnostics().visible, 0);
showing = true;
assert.equal(reader.snapshot(), null, "reopening must wait for a complete new projection");
visible.value = 1;
assert.ok(reader.snapshot());
exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe] = () => { throw new Error("unavailable"); };
assert.equal(createWorldMapFrameSpikeReader(exports)?.snapshot(), null);
delete exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe];
assert.equal(createWorldMapFrameSpikeReader(exports), null);
});
Loading