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
19 changes: 14 additions & 5 deletions compiler/backend/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,10 +878,7 @@ class Scope {
if (!of || !nativeArray(of) || of.slice || !this.ok(a.slice.offset)) return null;
return { array: of, index: merged(index.c, [...index.terms, { id: a.slice.offset, k: 1 }]), jump: true, guard: index, limit: a.length, ...(safe ? { safe } : {}) };
}
if (a.values) {
const bits = a.values.reduce((m, v) => (m | (v >>> 0)) >>> 0, 0).toString(2).replace(/0/g, "").length;
if (bits <= TABLE_BITS) return { array: a, index };
}
if (a.values && readInPlace(a.values)) return { array: a, index };
return { array: a, index, jump: true, ...(safe ? { safe } : {}) };
}

Expand Down Expand Up @@ -1148,6 +1145,8 @@ const PAD = 0x0d;
* bit, each 13 ns when the bit is clear and 23 when set, against about 200 ns for the Python side's read of a cell.
*/
const TABLE_BITS = 6;
/** The longest list known when built that is kept as cell triggers whatever its values (a longer one is read in place). */
const TABLE_CELLS_MOST = 16384;

/** The most cells a map of our own's stack has: a trigger of 72 bytes each (4.7 MB at this). */
const STACK_CELLS_MOST = 65536;
Expand All @@ -1163,10 +1162,20 @@ function inRegion(arrays: Map<string, ArrayDecl>, id: string): boolean {
const of = arrays.get(a.slice.of);
return !!of && nativeArray(of) && !of.slice;
}
if (a.values) return a.values.reduce((m, v) => (m | (v >>> 0)) >>> 0, 0).toString(2).replace(/0/g, "").length > TABLE_BITS;
if (a.values) return !readInPlace(a.values);
return true;
}

/**
* A list known when the map is built that is read a bit at a time where it is, not kept as a region of cell triggers:
* one whose values have few bits between them, or one too long to keep as cells — a video's brushes for the fog,
* hundreds of thousands of words, would be tens of megabytes of cells and pass the cells a map lays out; in place it is
* a dword a value, and a read is a trigger a bit the values have (Bad Apple's: 18).
*/
function readInPlace(values: readonly number[]): boolean {
return values.length > TABLE_CELLS_MOST || values.reduce((m, v) => (m | (v >>> 0)) >>> 0, 0).toString(2).replace(/0/g, "").length <= TABLE_BITS;
}

/**
* An array a map of our own keeps in a region of cell triggers (or reads where it is: a list of few bits): of numbers or
* booleans, one for everyone — not one that grows, holds texts or is a task's. A window on one (a row of a grid) is the
Expand Down
2 changes: 1 addition & 1 deletion docs/v4-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -5675,7 +5675,7 @@ and `sweep3.md` of 2026-10-23, 119 scripts) comes to:
| Built both ways, lines differ because the game is random | tetris, snake (the board drawn from random pieces: played by hand, the pack) |
| Built both ways, eudplib's lines wrong | own-fields ("C: a unit count WRONG" from eudplib, ok from ours), key-held (eudplib prints garbage where ours prints "taps 0 let go 0") |
| Refused by the own build | leftovers (a function that calls itself and gives back a text), strings (a unit's name written from a made text), game-closures and game-references (the length of the texts' address array of records kept in an array that grows) |
| Not built here | bad-apple, halftone, sound-probe (their video, image and base map are not on this disk) |
| Not built here | bad-apple, halftone, sound-probe (their video, image and base map are not on this disk). Built after 4.0 (2026-10-25): bad-apple was **refused** — its 657,477 brush words, 18 bits wide, laid out as cell triggers past the 65,536 — and is built since a list longer than 16,384 is read where it is (`readInPlace`), all 5,216 frames equal their cover in the lifted game, game-check clean; halftone builds |
| `game-*.ts` tests in the game on the own build, all passed | arrays-cost 7, async 3, clamps 2, cursors 4, emitter 10, errors 7, fdiv 7, fixed 6, function-values, generators 3, location-stale 2, math 4, program 3, ranges 5, short-circuit 1, small-gaps, stdlib-core 5, tasks 4 (after the fix above), thresholds 2, ui 1, unit-order 3, values 3, unit-pixels 1 |
| `game-*.ts` tests failing in the game on the own build | events, events-2, events-input, key-held, key-tap, local, local-players, players, tests, raycaster, cutscene, lowering — the harness's events and input on a map of our own (the Open list), to be looked at one by one in step 17; text-arrays past the lifted game's 64 KB of strings |

Expand Down
62 changes: 42 additions & 20 deletions probes/bad-apple.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,57 @@
// Bad Apple!! in the fog of war (TrigScript 4, S4 in TrigScript: B8). The video is imported like a file of the
// script — a .video, the frames the editor decoded — and the fog library plays it on the minimap a frame of it every
// frame of the game, with the song from the map's archive. Host it as a multiplayer game, alone, with Classic graphics.
// Bad Apple!! in the fog of war: the TrigScript showcase map. The video is imported like a file of the script — a
// .video, the frames the editor decoded — and the fog library plays it on the minimap, a frame of it every frame of
// the game, with the song from the map's archive. Host it as a multiplayer game, alone, with Classic graphics.
//
// The video and the song are not ours and never committed. Made with:
// The map is 128 × 128 Badlands with no units. Its top 96 rows (the board, 4:3) are all tile 23778, white when lit
// and grey in fog on the Classic minimap; the rows below are never seen, so they stay black around the picture, and
// the camera waits there. The video and the song are not ours and never committed. Made with:
// npx tsx scripts/import-video.mts <video file> probes/bad-apple.video --size=128x96
// ffmpeg -i <video file> -vn -ac 2 -ar 44100 -c:a libvorbis -q:a 3 <song.ogg>
// BA_ERA=1 BA_TILE=23778 npx tsx probes/lib/bad-apple-map.mts <base.scx> <song.ogg> 'staredit\wav\badapple.ogg'
// npx tsx scripts/build-fixture.mts probes/bad-apple.ts <map.scx> <base.scx>
// BA_ERA=0 BA_TILE=23778 npx tsx probes/lib/bad-apple-map.mts <base.scx> <song.ogg> 'staredit\wav\badapple.ogg'
// NO_BASES=1 npx tsx scripts/build-fixture.mts probes/bad-apple.ts <map.scx> <base.scx>
import { fog } from "trigscript/fog";
import badApple from "./bad-apple.video";

const W = 128, H = 96;
/** Where the camera waits, below the board: the minimap draws the camera's box over what is under it. */
const PARK = reserveLocation("bad apple: the camera, below the board");

/** Rows `from` to `to` of the board lit, as a picture: one step of the curtain the video opens and closes behind. */
const band = (from: number, to: number) =>
fog.picture(Array.from({ length: H }, (_, y) => (y >= from && y < to ? "#" : ".").repeat(W)));
const STEP = 4;
const board = band(0, H);
/** White falls over the board from the top, a second long. */
const fall = Array.from({ length: H / STEP }, (_, i) => band(0, (i + 1) * STEP));
/** The white slides off the bottom, leaving the board in fog. */
const lift = Array.from({ length: H / STEP - 1 }, (_, i) => band((i + 1) * STEP, H));

const credits = () => {
displayText("\x04Bad Apple!! feat. nomico");
displayText("\x05Alstroemeria Records, after a Touhou Project song by ZUN. Shadow art video by Anira.");
};

program(async () => {
// The base's units would light fog of their own.
removeUnit(P1, units.AnyUnit);
removeUnit(P2, units.AnyUnit);
centerLocation(PARK, 64 * 32, 112 * 32);
centerLocation(PARK, 64 * 32, 116 * 32);
centerView(PARK);
displayText("\x04Bad Apple!! in the fog of war. The video plays on the MINIMAP.");
displayText("\x11Classic graphics (Options > Graphics) draws the minimap sharp, Remastered soft; a snow board is brighter in Remastered.");
await nextFrame();
// The whole board lit once, so a dark part of a frame is fog grey, not the black of a tile never seen.
fog.reveal(P1);
await sleep(frames(118));
for (const s of [5, 4, 3, 2, 1]) {
displayText(`\x04Starting in ${s}...`);
await sleep(frames(24));
}
await sleep(frames(24));
credits();
displayText("\x05Played on the minimap with the fog of war, written in TrigScript.");
for (const p of fall) { fog.light(P1, 0, 0, p); await nextFrame(); }
let i = 0;
while (i < 72) { fog.light(P1, 0, 0, board); await nextFrame(); i++; }
for (const p of lift) { fog.light(P1, 0, 0, p); await nextFrame(); }
await sleep(frames(12));

playWav("staredit\\wav\\badapple.ogg", 0);
await fog.play(P1, badApple);

// The song runs a little past the last frame.
await sleep(frames(48));
for (const p of fall) { fog.light(P1, 0, 0, p); await nextFrame(); }
displayText("\x04Fin.");
credits();
i = 0;
while (i < 120) { fog.light(P1, 0, 0, board); await nextFrame(); i++; }
for (const p of lift) { fog.light(P1, 0, 0, p); await nextFrame(); }
});
2 changes: 1 addition & 1 deletion probes/lib/bad-apple-map.mts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const isom = flat.isom;
const scn = createScenario({
width: W, height: H, era: ERA, tiles, isom,
name: "Bad Apple!! in the fog of war",
description: "The video drawn on the minimap with fog of war. Switch to Classic graphics (Options > Graphics) for a sharp minimap. Host as multiplayer, alone.",
description: "Bad Apple!! feat. nomico (Alstroemeria Records; shadow art video by Anira), played on the minimap with the fog of war and written in TrigScript. Host a multiplayer game alone and switch to Classic graphics (Options > Graphics) for a sharp minimap.",
});
scn.playerTypes = scn.playerTypes.map((t: number, i: number) => (i === 0 ? 6 : i === 1 ? 5 : i < 8 ? 0 : t));
scn.editorPlayerTypes = [...scn.playerTypes];
Expand Down
6 changes: 4 additions & 2 deletions scripts/build-fixture.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* The map is prepared through the editor's own code (a scm-js checkout beside this repository, or SCMJS_DIR): players,
* a base each, and the script's `trigger()` records with their texts interned. TRIGGER_MAP=1 and VARIABLE_MAP=1 write
* where each trigger and each variable is beside the map (<out>.triggers.json, <out>.variables.json), for scm-sim's
* run in the lifted game and `sim.program()`. MAP_NAME names the scenario (what the lobby shows); KEEP_TRIGGERS=1 keeps
* run in the lifted game and `sim.program()`. MAP_NAME names the scenario (what the lobby shows); NO_BASES=1 places no
* base and troops at the start locations; KEEP_TRIGGERS=1 keeps
* the base map's own triggers before the script's; STACK_DEPTH and HEAP_CELLS are the script's settings; OWN_FREE a
* dword written into every free cell of the payload.
*/
Expand Down Expand Up @@ -64,7 +65,8 @@ scn.triggers = [...(process.env.KEEP_TRIGGERS ? scn.triggers : []), ...resolveSt
const START_LOCATION = 214, COMMAND_CENTER = 106, HATCHERY = 131, MARINE = 0, ZERGLING = 37;
const placed = [];
let serial = nextSerial(scn);
for (const [owner, base, troop] of [[0, COMMAND_CENTER, MARINE], [1, HATCHERY, ZERGLING]] as const) {
// (NO_BASES=1: none — a map whose own units, or the lack of them, are the point, such as one drawn in the fog.)
for (const [owner, base, troop] of process.env.NO_BASES ? [] : [[0, COMMAND_CENTER, MARINE], [1, HATCHERY, ZERGLING]] as const) {
const start = scn.units.find((u: { unitId: number; owner: number }) => u.unitId === START_LOCATION && u.owner === owner);
if (!start) continue;
placed.push(makeUnit(null, base, owner, start.x, start.y, serial++));
Expand Down
13 changes: 13 additions & 0 deletions tests/own-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ describe("the build in the editor", () => {
expect(fixed.at).toBeGreaterThanOrEqual(out.stats.address);
});

it("a list known when built too long to keep as cells is read where it is, whatever its values' bits", () => {
// (70,000 values of 20 bits: as cell triggers 5 MB, past the cells a map lays out — a video's brushes for the fog.)
const source = `const T = Array.from({ length: 70000 }, (_, i) => (i * 40503 + 7) % 1048573);\nprogram(() => {\n let f = 0;\n const at = [0, 1, 34999, 69999];\n while (f < 4) {\n const k = at[f];\n displayText(\`\${k}:\${T[k]}\`);\n f++;\n sleep(frames(1));\n }\n});`;
const r = compile(source);
const out = buildOwnMap({ chk: scenario(), compiled: r });
if (isRefused(out)) throw new Error(out.refused);
// A dword a value, not 72 bytes.
expect(out.stats.payloadBytes).toBeLessThan(70000 * 8);
const walked = walkBuiltMap(out.chk, out.stats.players, out.stats.entries, 4);
const want = [0, 1, 34999, 69999].map((k) => `${k}:${(k * 40503 + 7) % 1048573}`);
expect(walked.printed.map((p) => p.text)).toEqual(want);
});

it("the built file walked as the game walks it prints what the simulator prints, and its variables read as the simulator's", () => {
const source = `program(() => {\n let n = 0;\n let seen: number[] = [];\n let fixed = [10, 20, 30];\n let title = "Probe";\n while (true) {\n n++;\n seen.push(n * 2);\n fixed[n % 3] += n;\n if (n === 2) title = "Second";\n displayText(\`\${title} \${n} \${seen.length} \${fixed[1]}\`);\n sleep(frames(1));\n }\n}, { name: "counting" });\nprogram(() => {\n let mine = 0;\n while (true) { mine += 3; displayText(\`mine \${mine}\`); sleep(frames(1)); }\n}, { owner: AllPlayers, name: "each" });`;
const r = compile(source);
Expand Down
Loading