From 1ef216575df551364e19b88ae87fc26fa6b3ddfc Mon Sep 17 00:00:00 2001 From: Jeany Date: Sun, 25 Oct 2026 17:37:55 -0600 Subject: [PATCH 1/4] A unit's underAttack is its last event being a hit, not the alert timer Remastered, Magenta probe 6 (2026-10-25): marines bitten on screen for ten seconds never read as under attack. The attack-notify timer at +0x87 runs only when the "under attack" alert sounds; the last-event bytes (+0x8A timer, +0x8B 174 = was hit) are what BWAPI reads, true for about a second after a hit. The lifted-game view reads the same bytes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/units.ts | 10 ++++++++-- compiler/gameSim.ts | 4 +++- tests/emitter.test.ts | 5 +++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index d1f3186..6ea0995 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -67,7 +67,13 @@ const FIELDS: Partial> = { stim: word(0x115, 1), ensnare: word(0x116, 1), lockdown: word(0x117, 1), irradiate: word(0x118, 1), stasis: word(0x119, 1), plague: word(0x11a, 1), maelstrom: word(0x124, 1), buildTime: word(0xac, 2), rank: word(0x8e, 1), matrix: word(0x114, 1), speed: word(0x38, 4), }; -const HP = 0x08 >> 2, SHIELDS = 0x60 >> 2, ENERGY = word(0xa2, 2), STATUS = 0xdc >> 2, ATTACKED = word(0x87, 1), USER_ACTION = word(0x93, 1); +const HP = 0x08 >> 2, SHIELDS = 0x60 >> 2, ENERGY = word(0xa2, 2), STATUS = 0xdc >> 2, USER_ACTION = word(0x93, 1); +/** + * "Under attack": the unit's last event is a hit (+0x8B, 174) and its timer (+0x8A) is still running — about a second, + * as BWAPI reads it. (Not +0x87, the attack-notify timer: that one runs only when the "under attack" alert sounds, so a + * unit hit on screen in a game played alone never shows it — Remastered, Magenta probe 6, 2026-10-25.) + */ +const LAST_EVENT = 0x88 >> 2, LAST_EVENT_MASK = 0xffff0000, HIT_EVENT = 174, HIT_LEAST = (HIT_EVENT * 2 ** 24 + 2 ** 16) >>> 0, HIT_MOST = (HIT_EVENT * 2 ** 24 + 0xff * 2 ** 16) >>> 0; /** The status bits a flag is (cloaked: both of two). */ const FLAG_BITS: Partial> = { invincible: 0x04000000, hallucinated: 0x40000000, burrowed: 0x10, cloaked: 0x300, speedUpgrade: 0x10000000, inTransport: 0x40 }; /** The unit an order is aimed at (a pointer, 0 for none), and the defensive matrix's hit points (a word, 256 a point) beside its timer. */ @@ -691,7 +697,7 @@ export function unitValues(program: Program, events?: { sources: Set { if (isNone(u)) return { kind: "const", value: false }; u = held(u, pre); - if (f === "underAttack") return all([alive(u), cmp(">=", bits(u.e, ATTACKED), k(1))]); + if (f === "underAttack") return all([alive(u), cmp(">=", peek(of(u.e, LAST_EVENT), LAST_EVENT_MASK), k(HIT_LEAST)), cmp("<=", peek(of(u.e, LAST_EVENT), LAST_EVENT_MASK), k(HIT_MOST))]); if (f === "hasTarget") return all([alive(u), cmp(">=", peek(of(u.e, ORDER_TARGET)), k(1))]); const mask = FLAG_BITS[f]; if (mask === undefined) throw new Leave(); diff --git a/compiler/gameSim.ts b/compiler/gameSim.ts index 848da83..f9f6cdf 100644 --- a/compiler/gameSim.ts +++ b/compiler/gameSim.ts @@ -206,7 +206,9 @@ class GameUnit implements SimUnit { get hasTarget() { return this.read("hasTarget", () => this.m.getUint32(this.at + 0x5c, true) !== 0); } get inTransport() { return this.read("inTransport", () => this.flag(0x00000040)); } // Not read from the game yet: the spell timers and whether the unit is under attack. - stim = 0; ensnare = 0; plague = 0; lockdown = 0; maelstrom = 0; irradiate = 0; stasis = 0; underAttack = false; + stim = 0; ensnare = 0; plague = 0; lockdown = 0; maelstrom = 0; irradiate = 0; stasis = 0; + /** The last event a hit (+0x8B is 174) and its timer (+0x8A) still running: about a second after something hit the unit. */ + get underAttack() { return this.read("underAttack", () => this.game.u8[this.at + 0x8a] !== 0 && this.game.u8[this.at + 0x8b] === 174); } } export class GameSim implements TestWorldLike { diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index abee5f9..1a02ff8 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -1481,6 +1481,7 @@ describe("a map of our own, units", () => { put(at + 0x38, u.speed ?? 0); put(at + 0x5c, u.hasTarget ? 0x700000 + 4 : 0); put(at + 0x8e, u.rank ?? 0, 1); + if (u.underAttack) { put(at + 0x8a, 12, 1); put(at + 0x8b, 174, 1); } put(at + 0xac, u.buildTime ?? 0, 2); put(at + 0x114, u.matrix ?? 0, 1); put(at + 0xdc, (u.invincible ? 0x04000000 : 0) | (u.hallucinated ? 0x40000000 : 0) | (u.speedUpgrade ? 0x10000000 : 0) | (u.inTransport ? 0x40 : 0) | (u.burrowed ? 0x10 : 0)); @@ -1586,7 +1587,7 @@ describe("a map of our own, units", () => { const units: SimUnitInit[] = [ { type: 106, owner: 0, x: 400, y: 300, buildTime: 900, rank: 2, speed: 0 }, { type: 0, owner: 0, x: 420, y: 340, speed: 1024, hasTarget: true, speedUpgrade: true, matrix: 100 }, - { type: 0, owner: 0, x: 430, y: 330, inTransport: true, hallucinated: true }, + { type: 0, owner: 0, x: 430, y: 330, inTransport: true, hallucinated: true, underAttack: true }, ]; same([ " const cc = first({ type: units.TerranCommandCenter })!;", @@ -1595,7 +1596,7 @@ describe("a map of our own, units", () => { " let second: Unit | null = null;", " for (const u of unitsOf(P1, { type: units.TerranMarine })) second = u;", " print(`${cc.buildTime} ${cc.rank} ${cc.speed} ${m.speed} ${m.hasTarget} ${m.speedUpgrade} ${m.matrix} ${m.inTransport} ${m.hallucinated}`);", - " if (second) print(`${second.inTransport} ${second.hallucinated} ${second.hasTarget} ${second.speedUpgrade}`);", + " if (second) print(`${second.inTransport} ${second.hallucinated} ${second.hasTarget} ${second.speedUpgrade} ${second.underAttack} ${m.underAttack}`);", " cc.buildTime = 60; cc.rank = 7; m.matrix = 40; m.speedUpgrade = false; m.hallucinated = true; cc.rank += 1;", " print(`${cc.buildTime} ${cc.rank} ${m.matrix} ${m.speedUpgrade} ${m.hallucinated}`);", " m.matrix = 0; cc.buildTime = 70000; m.rank = 300;", From 4663c3c2f82abb1e76aee835f8c20bd9fd14d131 Mon Sep 17 00:00:00 2001 From: Jeany Date: Sun, 25 Oct 2026 17:38:50 -0600 Subject: [PATCH 2/4] buildTime: a building finished early keeps its hit points, and a Terran one under a third burns Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- README.md | 3 ++- compiler/declarations.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9d74e35..bb621e5 100644 --- a/README.md +++ b/README.md @@ -1456,7 +1456,8 @@ if (target) target.order("move", locations.Exit); A unit has `hp`, `shields` and `energy` in whole points, `maxHp` / `maxShields` of its type, `owner`, `type`, `x`, `y`, `kills`, `orderId`, `cooldown`, `resources` (a mineral -field's), `buildTime` (frames left of its making), `rank`, `matrix` (frames left of a +field's), `buildTime` (frames left of its making; a building finished early keeps the hit +points it had reached, and a Terran one under a third burns, so heal it after), `rank`, `matrix` (frames left of a defensive matrix), `speed` (how fast it is moving now, in 256ths of a pixel a frame), the timers `stim` `ensnare` `plague` `lockdown` `maelstrom` `irradiate` `stasis` in the game's own ticks, and the booleans `invincible`, `hallucinated`, `speedUpgrade`, `cloaked`, diff --git a/compiler/declarations.ts b/compiler/declarations.ts index 623778d..7ce48d6 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -147,7 +147,11 @@ ${kw}interface Unit { readonly orderId: Order; /** Frames until the unit can attack or cast again, 0 … 255; writing it holds the unit's fire that long (the ground weapon's, the air weapon's and the spells' alike). */ cooldown: number; - /** Frames left of a unit's or building's making, 0 … 65535; 0 once complete. Writing it lengthens or shortens the making. */ + /** + * Frames left of a unit's or building's making, 0 … 65535; 0 once complete. Writing it lengthens or shortens the making. + * A building finished early keeps the hit points it had reached, and a Terran one under a third of its full hit points + * burns down (Remastered, played): heal it after. + */ buildTime: number; /** The unit's rank, added to the type's (the one shown on its panel: Private, Corporal …), 0 … 255. */ rank: number; From 91d2c5e6a47e05d50c2c2edea52162e06e12e8cf Mon Sep 17 00:00:00 2001 From: Jeany Date: Sun, 25 Oct 2026 17:50:23 -0600 Subject: [PATCH 3/4] underAttack stays on the attack-notify timer, said for what it is: Remastered ends the game on the last-event bytes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- README.md | 4 +++- compiler/backend/units.ts | 12 +++++++----- compiler/declarations.ts | 5 ++++- compiler/gameSim.ts | 4 ++-- tests/emitter.test.ts | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bb621e5..d334a07 100644 --- a/README.md +++ b/README.md @@ -1461,7 +1461,9 @@ points it had reached, and a Terran one under a third burns, so heal it after), defensive matrix), `speed` (how fast it is moving now, in 256ths of a pixel a frame), the timers `stim` `ensnare` `plague` `lockdown` `maelstrom` `irradiate` `stasis` in the game's own ticks, and the booleans `invincible`, `hallucinated`, `speedUpgrade`, `cloaked`, -`burrowed`, `underAttack`, `hasTarget` (its order is aimed at a unit) and `inTransport`. +`burrowed`, `underAttack` (the game raised its under-attack alert for it within the last +second or so, which it does for a hit out of the player's view, not one on screen), +`hasTarget` (its order is aimed at a unit) and `inTransport`. Hit points, shields, energy, kills, the cooldown (the ground weapon's, the air weapon's and the spells' at once), the resources, the build time, the rank, the matrix (a write gives 250 points of matrix for that many frames; 0 takes it away), the timers, `invincible`, diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index 6ea0995..bb3765e 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -69,11 +69,13 @@ const FIELDS: Partial> = { }; const HP = 0x08 >> 2, SHIELDS = 0x60 >> 2, ENERGY = word(0xa2, 2), STATUS = 0xdc >> 2, USER_ACTION = word(0x93, 1); /** - * "Under attack": the unit's last event is a hit (+0x8B, 174) and its timer (+0x8A) is still running — about a second, - * as BWAPI reads it. (Not +0x87, the attack-notify timer: that one runs only when the "under attack" alert sounds, so a - * unit hit on screen in a game played alone never shows it — Remastered, Magenta probe 6, 2026-10-25.) + * "Under attack": the attack-notify timer (+0x87), which the game starts when it raises its "under attack" alert for the + * unit — a hit out of the player's view — and runs for about a second. A unit hit on screen in a game played alone does + * not show it (Remastered, Magenta probe 6, 2026-10-25). The last-event bytes BWAPI reads instead (+0x8A timer, +0x8B + * 174 = was hit) are in a dword Remastered's EUD emulator ends the game on (data/allowlist.json, +0x88), so this is the + * one "recently hit" signal a map may read. */ -const LAST_EVENT = 0x88 >> 2, LAST_EVENT_MASK = 0xffff0000, HIT_EVENT = 174, HIT_LEAST = (HIT_EVENT * 2 ** 24 + 2 ** 16) >>> 0, HIT_MOST = (HIT_EVENT * 2 ** 24 + 0xff * 2 ** 16) >>> 0; +const ATTACKED = word(0x87, 1); /** The status bits a flag is (cloaked: both of two). */ const FLAG_BITS: Partial> = { invincible: 0x04000000, hallucinated: 0x40000000, burrowed: 0x10, cloaked: 0x300, speedUpgrade: 0x10000000, inTransport: 0x40 }; /** The unit an order is aimed at (a pointer, 0 for none), and the defensive matrix's hit points (a word, 256 a point) beside its timer. */ @@ -697,7 +699,7 @@ export function unitValues(program: Program, events?: { sources: Set { if (isNone(u)) return { kind: "const", value: false }; u = held(u, pre); - if (f === "underAttack") return all([alive(u), cmp(">=", peek(of(u.e, LAST_EVENT), LAST_EVENT_MASK), k(HIT_LEAST)), cmp("<=", peek(of(u.e, LAST_EVENT), LAST_EVENT_MASK), k(HIT_MOST))]); + if (f === "underAttack") return all([alive(u), cmp(">=", bits(u.e, ATTACKED), k(1))]); if (f === "hasTarget") return all([alive(u), cmp(">=", peek(of(u.e, ORDER_TARGET)), k(1))]); const mask = FLAG_BITS[f]; if (mask === undefined) throw new Leave(); diff --git a/compiler/declarations.ts b/compiler/declarations.ts index 7ce48d6..e9cf7e4 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -177,7 +177,10 @@ ${kw}interface Unit { speedUpgrade: boolean; readonly cloaked: boolean; readonly burrowed: boolean; - /** True for about a second after something hit the unit. */ + /** + * True for about a second after the game raised its "under attack" alert for the unit: a hit out of the player's view. + * A unit hit on the player's screen does not count — the game keeps no other "recently hit" mark a map may read. + */ readonly underAttack: boolean; /** True while the unit's order is aimed at another unit (attacking, following, repairing …). */ readonly hasTarget: boolean; diff --git a/compiler/gameSim.ts b/compiler/gameSim.ts index f9f6cdf..18820ef 100644 --- a/compiler/gameSim.ts +++ b/compiler/gameSim.ts @@ -207,8 +207,8 @@ class GameUnit implements SimUnit { get inTransport() { return this.read("inTransport", () => this.flag(0x00000040)); } // Not read from the game yet: the spell timers and whether the unit is under attack. stim = 0; ensnare = 0; plague = 0; lockdown = 0; maelstrom = 0; irradiate = 0; stasis = 0; - /** The last event a hit (+0x8B is 174) and its timer (+0x8A) still running: about a second after something hit the unit. */ - get underAttack() { return this.read("underAttack", () => this.game.u8[this.at + 0x8a] !== 0 && this.game.u8[this.at + 0x8b] === 174); } + /** The attack-notify timer (+0x87) running: the game raised its under-attack alert for the unit within the last second or so. */ + get underAttack() { return this.read("underAttack", () => this.game.u8[this.at + 0x87] !== 0); } } export class GameSim implements TestWorldLike { diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 1a02ff8..4f2ba85 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -1481,7 +1481,7 @@ describe("a map of our own, units", () => { put(at + 0x38, u.speed ?? 0); put(at + 0x5c, u.hasTarget ? 0x700000 + 4 : 0); put(at + 0x8e, u.rank ?? 0, 1); - if (u.underAttack) { put(at + 0x8a, 12, 1); put(at + 0x8b, 174, 1); } + if (u.underAttack) put(at + 0x87, 12, 1); put(at + 0xac, u.buildTime ?? 0, 2); put(at + 0x114, u.matrix ?? 0, 1); put(at + 0xdc, (u.invincible ? 0x04000000 : 0) | (u.hallucinated ? 0x40000000 : 0) | (u.speedUpgrade ? 0x10000000 : 0) | (u.inTransport ? 0x40 : 0) | (u.burrowed ? 0x10 : 0)); From c273bea9b3b14258393907d71d3c89f8ee3d4af1 Mon Sep 17 00:00:00 2001 From: Jeany Date: Sun, 25 Oct 2026 18:32:57 -0600 Subject: [PATCH 4/4] emitter test: a location's size read into consts, then its edges written from them (Magenta's put-back row) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- tests/emitter.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 4f2ba85..f2e6204 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -1618,6 +1618,24 @@ describe("a map of our own, units", () => { ].join("\n"), UNITS, HOME); }); + it("reads a location's size into consts and writes its edges from them, as Magenta's put-back row does", () => { + same([ + " const b = bounds(1 as Location);", + " const w = b.right - b.left;", + " const h = b.bottom - b.top;", + " b.left = 576; b.top = 256;", + " b.right = 576 + w; b.bottom = 256 + h;", + " print(`${b.left} ${b.top} ${b.right} ${b.bottom}`);", + " const c = bounds(1 as Location);", + " c.left += 64; c.right += 64;", + " print(`${c.left} ${c.right}`);", + " const d = bounds(1 as Location);", + " const w2 = d.right - d.left;", + " d.left = 100; d.right = 100 + w2;", + " print(`${d.left} ${d.right}`);", + ].join("\n"), UNITS, HOME); + }); + it("walks a table every place of which has been handed out once, place 0 among them, one place a turn and two", () => { const full: SimUnitInit[] = Array.from({ length: 1700 }, (_, i) => ({ type: i % 3 ? 0 : 37, owner: i % 3 ? 0 : 1, x: 100 + (i % 50), y: 100 + Math.floor(i / 50) })); same([