diff --git a/AGENTS.md b/AGENTS.md index a8072a1..e80004c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,7 @@ Three tiers, cheapest first. Put a test in the cheapest tier that can actually c - `e2e/harness/vscodeLauncher.js` pins `workbench.editorAssociations` in the temp profile. Without it a `.w3u`/`.w3a` passed on the command line opens in the *text* editor on a cold `--extensionDevelopmentPath` start, because the extension host has not registered its custom editors yet — and no webview is ever created. ### Editable binary formats +- `.imp` remains intentionally read-only: it is World Editor import-manager bookkeeping, not a useful standalone editing target for normal Wurst workflows. - **.w3i is an editable custom editor** (`wurst.w3iEditor`, in `mapDataPreview.ts`) backed by `casc-ts` `parseW3i`/`serializeW3i`, which use a **parse-prefix + opaque-tail** model: only leading string/scalar fields are editable; players/forces/lists are preserved verbatim in `file.tail` (and parsed best-effort for display only). Every save passes a round-trip safety gate (`serializeValidatedW3i`). TRIGSTR-backed strings edit `war3map.wts`; inline strings edit the w3i bytes. The other map-data formats remain read-only under `wurst.mapDataPreview` (the old read-only `renderW3i`/`parseW3i` in that file are retained but no longer routed to). - When adding a new editable binary format, mirror this: a casc-ts parser+serializer with a byte-exact round-trip test, a `CustomEditorProvider` with dirty tracking, and a serialize→re-parse→compare safety gate before any write. diff --git a/e2e/harness/makeFixtures.js b/e2e/harness/makeFixtures.js index bd7a373..e023e1e 100644 --- a/e2e/harness/makeFixtures.js +++ b/e2e/harness/makeFixtures.js @@ -118,6 +118,7 @@ function buildWpm() { for (let y = 4; y < 8; y++) { for (let x = 4; x < 8; x++) data[y * width + x] = 0x02; // walkability blocked } + data[7 * width + 9] = 0xd0; // unfloatable + unamphibious + no-peon-harvest: picker must preserve every bit return serializeWpm({ version: 0, width, height, data, tail: Buffer.alloc(0) }); } diff --git a/e2e/specs/wpm-editor.spec.js b/e2e/specs/wpm-editor.spec.js index 1ea5ad3..685917d 100644 --- a/e2e/specs/wpm-editor.spec.js +++ b/e2e/specs/wpm-editor.spec.js @@ -128,6 +128,47 @@ test('a drag paints a line of cells as one undo step', async ({ openWpm }) => { await expect.poll(() => host.isDirty).toBe(false); }); +test('the brush size expands one gesture into a square of cells', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + await page.click('[data-tool="paint"]'); + await page.fill('#brushSize', '3'); + await expect(page.locator('#brushSizeValue')).toHaveText('3 × 3'); + + const box = await page.locator('#viewport').boundingBox(); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + await expect.poll(() => host.editLabels.length).toBe(1); + const painted = Number(/Paint (\d+)/.exec(host.editLabels[0])[1]); + expect(painted).toBeGreaterThan(1); + expect(painted).toBeLessThanOrEqual(9); +}); + +test('line and fill tools produce one undoable edit each', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + const box = await page.locator('#viewport').boundingBox(); + + await page.click('[data-tool="line"]'); + const y = box.y + box.height / 2; + await page.mouse.move(box.x + box.width * 0.3, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.7, y, { steps: 4 }); + await page.mouse.up(); + await expect.poll(() => host.editLabels.length).toBe(1); + expect(host.editLabels[0]).toMatch(/^Paint \d+ pathing cells$/); + + await page.click('[data-tool="fill"]'); + await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.8); + await expect.poll(() => host.editLabels.length).toBe(2); + expect(host.editLabels[1]).toMatch(/^Paint \d+ pathing cells$/); + + host.undo(); + await expect.poll(() => host.editLabels.length).toBe(2); + host.undo(); + await expect.poll(() => host.isDirty).toBe(false); +}); + test('the erase tool clears flags instead of painting them', async ({ openWpm }) => { const { page, host } = await openWpm(); await page.click('#btnZoomFit'); @@ -171,6 +212,23 @@ test('Alt+click picks up the brush flags from the cell under the cursor', async await expect(page.locator('[data-tool="paint"]')).toHaveClass(/active/); }); +test('Alt-click samples the complete flag byte from a cell', async ({ openWpm }) => { + const { page } = await openWpm(); + await page.click('#btnZoomFit'); + + const box = await page.locator('#viewport').boundingBox(); + const cellSize = await page.locator('#wpmCanvas').evaluate((canvas) => Math.min(canvas.width / 16, canvas.height / 16)); + await page.keyboard.down('Alt'); + await page.mouse.click(box.x + box.width / 2 + cellSize, box.y + box.height / 2); + await page.keyboard.up('Alt'); + + await expect(page.locator('#brushValue')).toHaveText('0xD0'); + await expect(page.locator('[data-brush-bit="16"]')).toBeChecked(); + await expect(page.locator('[data-brush-bit="64"]')).toBeChecked(); + await expect(page.locator('[data-brush-bit="128"]')).toBeChecked(); + await expect(page.locator('[data-tool="paint"]')).toHaveClass(/active/); +}); + test('saving writes bytes that re-parse to the painted map', async ({ openWpm }) => { const { page, host } = await openWpm(); await page.click('#btnZoomFit'); diff --git a/scripts/test-webview.js b/scripts/test-webview.js index ab2fda9..48a300f 100644 --- a/scripts/test-webview.js +++ b/scripts/test-webview.js @@ -1088,6 +1088,8 @@ function testWpmEditorInlineScriptAndRecoveryGuards() { .replace('${wpm.width}', '4') .replace('${wpm.height}', '4') .replace('${dataBase64}', 'AAAAAAAAAAAAAAAAAAAAAA==') + .replace('${JSON.stringify(colorTable)}', '[[0, 0, 0]]') + .replace('${JSON.stringify(WPM_FLAG_DEFS.map(({ bit, label }) => ({ bit, label })))}', '[]') .replace(/\\`/g, '`') .replace(/\\\$\{/g, '${'); // eslint-disable-next-line sonarjs/constructor-for-side-effects -- parsing the real inline script is the assertion. @@ -1097,6 +1099,21 @@ function testWpmEditorInlineScriptAndRecoveryGuards() { assert.ok(!host.includes('doc.editDepth'), 'WPM dirty tracking must not use branch-unsafe edit depth'); } +function testWpmFlagSemantics() { + const { WPM_FLAG_DEFS, WPM_KNOWN_VERSION, wpmCellRgb, wpmFlagLabels } = loadTsModuleWithMocks('src/features/wpmPreview.ts', { + vscode: {}, + 'casc-ts/formats': {}, + './diagnostics': {}, + './webviewShared': {}, + './webviewUtils': {}, + }); + assert.strictEqual(WPM_KNOWN_VERSION, 0); + assert.deepStrictEqual(WPM_FLAG_DEFS.map((definition) => definition.bit), [1, 2, 4, 8, 16, 32, 64, 128]); + assert.deepStrictEqual(wpmFlagLabels(0xd0), ['No Peon Harvest', 'No Water / Unfloatable', 'Unamphibious']); + assert.deepStrictEqual(wpmCellRgb(0x0a), [255, 0, 255], 'primary pathing colors must remain RGB channels'); + assert.notDeepStrictEqual(wpmCellRgb(0x80), [0, 0, 0], 'amphibious cells must not render as an anonymous black cell'); +} + async function main() { testAssetPathNormalization(); testSignals(); @@ -1128,6 +1145,7 @@ async function main() { testObjModDensityAndTreeStyling(); testImportedAssetDedupeSafety(); testLocalE2eFixturesRemainOptIn(); + testWpmFlagSemantics(); console.log('webview harness tests passed'); } diff --git a/src/features/wpmPreview.ts b/src/features/wpmPreview.ts index b4ba8e3..615720c 100644 --- a/src/features/wpmPreview.ts +++ b/src/features/wpmPreview.ts @@ -9,56 +9,71 @@ import { buildPage } from './webviewShared'; import { escapeHtml } from './webviewUtils'; export { WpmFile } from 'casc-ts/formats'; +export interface WpmFlagDefinition { + bit: number; + label: string; + description: string; + color: [number, number, number]; + primary?: boolean; +} + +/** The only WPM header version with a documented byte layout. */ +export const WPM_KNOWN_VERSION = 0; + +export const WPM_FLAG_DEFS: readonly WpmFlagDefinition[] = [ + { bit: 0x01, label: 'Reserved', description: 'Reserved bit; standard maps normally leave it clear.', color: [160, 160, 160] }, + { bit: 0x02, label: 'Unwalkable', description: 'Ground units cannot walk through this cell.', color: [255, 0, 0], primary: true }, + { bit: 0x04, label: 'Unflyable', description: 'Flying units cannot pass through this cell.', color: [0, 255, 0], primary: true }, + { bit: 0x08, label: 'Unbuildable', description: 'Buildings cannot be placed on this cell.', color: [0, 0, 255], primary: true }, + { bit: 0x10, label: 'No Peon Harvest', description: 'Peons cannot harvest resources from this cell.', color: [240, 170, 40] }, + { bit: 0x20, label: 'Blighted', description: 'The cell is marked as blight.', color: [190, 80, 0] }, + { bit: 0x40, label: 'No Water / Unfloatable', description: 'The WC3 pathing state for no water / unfloatable movement. It is commonly set on ordinary dry ground; terrain water is stored in W3E.', color: [120, 120, 120] }, + { bit: 0x80, label: 'Unamphibious', description: 'The WC3 pathing state for amphibious movement.', color: [180, 80, 220] }, +]; + +function blendWpmColor(base: [number, number, number], overlay: [number, number, number]): [number, number, number] { + return [(base[0] + overlay[0]) >> 1, (base[1] + overlay[1]) >> 1, (base[2] + overlay[2]) >> 1]; +} + +export function wpmCellRgb(flag: number): [number, number, number] { + let rgb: [number, number, number] = [0, 0, 0]; + for (const definition of WPM_FLAG_DEFS) { + if ((flag & definition.bit) === 0) continue; + if (definition.primary) { + rgb = [ + definition.bit === 0x02 ? definition.color[0] : rgb[0], + definition.bit === 0x04 ? definition.color[1] : rgb[1], + definition.bit === 0x08 ? definition.color[2] : rgb[2], + ]; + } else { + rgb = blendWpmColor(rgb, definition.color); + } + } + return rgb; +} + +export function wpmFlagLabels(flag: number): string[] { + return WPM_FLAG_DEFS.filter((definition) => (flag & definition.bit) !== 0).map((definition) => definition.label); +} + +export function wpmColorTable(): Array<[number, number, number]> { + return Array.from({ length: 256 }, (_, flag) => wpmCellRgb(flag)); +} + // ── HTML Rendering ──────────────────────────────────────────────────────────── function buildWpmHtml(wpm: WpmFile, fileName: string, isDirty: boolean): string { const dataBase64 = wpm.data.toString('base64'); - // Color formula — must stay in sync with the ImageData loop in the @@ -510,12 +689,98 @@ class WpmDocument implements vscode.CustomDocument { dispose(): void {} } -interface WpmCellChange { - index: number; +interface WpmRequestedRun { + start: number; + length: number; + value: number; +} + +interface WpmRunChange extends WpmRequestedRun { before: number; after: number; } +interface WpmEditMessage { + type?: string; + changes?: Array<{ index?: number; value?: number }>; + runs?: Array<{ start?: number; length?: number; value?: number }>; +} + +function compactWpmRuns(changes: Array<{ index: number; value: number }>): WpmRequestedRun[] { + const sorted = changes.slice().sort((a, b) => a.index - b.index); + const runs: WpmRequestedRun[] = []; + for (const change of sorted) { + const previous = runs[runs.length - 1]; + if (previous && previous.start + previous.length === change.index && previous.value === change.value) { + previous.length++; + } else { + runs.push({ start: change.index, length: 1, value: change.value }); + } + } + return runs; +} + +function collectWpmCellRequests(changes: Array<{ index?: number; value?: number }>, dataLength: number): Map { + const requested = new Map(); + for (const change of changes) { + if (Number.isInteger(change.index) && Number.isInteger(change.value) && + (change.index as number) >= 0 && (change.index as number) < dataLength && + (change.value as number) >= 0 && (change.value as number) <= 0xff) { + requested.set(change.index as number, change.value as number); + } + } + return requested; +} + +function collectWpmRunRequests(runs: Array<{ start?: number; length?: number; value?: number }>, dataLength: number): WpmRequestedRun[] { + const requested: WpmRequestedRun[] = []; + for (const run of runs) { + const valid = Number.isInteger(run.start) && Number.isInteger(run.length) && Number.isInteger(run.value) && + (run.start as number) >= 0 && (run.length as number) >= 1 && + (run.start as number) + (run.length as number) <= dataLength && + (run.value as number) >= 0 && (run.value as number) <= 0xff; + if (valid) requested.push({ start: run.start as number, length: run.length as number, value: run.value as number }); + } + return requested.sort((a, b) => a.start - b.start); +} + +function collectWpmRequests(message: WpmEditMessage, dataLength: number): WpmRequestedRun[] { + if (message.type === 'editCells' && Array.isArray(message.changes)) { + const requested = collectWpmCellRequests(message.changes, dataLength); + return compactWpmRuns(Array.from(requested, ([index, value]) => ({ index, value }))); + } + if (message.type === 'editRuns' && Array.isArray(message.runs)) { + return collectWpmRunRequests(message.runs, dataLength); + } + return []; +} + +function buildWpmRunChanges(data: Buffer, requested: WpmRequestedRun[]): WpmRunChange[] { + const changes: WpmRunChange[] = []; + for (const run of requested) { + let start = run.start; + let before = data[start]; + let length = 1; + for (let offset = 1; offset < run.length; offset++) { + const nextBefore = data[run.start + offset]; + if (nextBefore === before) { + length++; + continue; + } + if (before !== run.value) changes.push({ start, length, value: run.value, before, after: run.value }); + start = run.start + offset; + before = nextBefore; + length = 1; + } + if (before !== run.value) changes.push({ start, length, value: run.value, before, after: run.value }); + } + return changes; +} + +function wpmRunCellCount(changes: WpmRunChange[]): number { + return changes.reduce((total, change) => total + change.length, 0); +} + class WpmEditorProvider implements vscode.CustomEditorProvider { private readonly _onDidChange = new vscode.EventEmitter>(); readonly onDidChangeCustomDocument = this._onDidChange.event; @@ -554,52 +819,41 @@ class WpmEditorProvider implements vscode.CustomEditorProvider { private handleMessage(message: unknown, doc: WpmDocument): void { if (!message || typeof message !== 'object') return; - const msg = message as { type?: string; changes?: Array<{ index?: number; value?: number }> }; - if (msg.type !== 'editCells' || !Array.isArray(msg.changes)) return; - - const requested = new Map(); - for (const change of msg.changes) { - if (Number.isInteger(change.index) && Number.isInteger(change.value) && - (change.index as number) >= 0 && (change.index as number) < doc.file.data.length && - (change.value as number) >= 0 && (change.value as number) <= 0xff) { - requested.set(change.index as number, change.value as number); - } - } - const changes: WpmCellChange[] = []; - requested.forEach((after, index) => { - const before = doc.file.data[index]; - if (after !== before) changes.push({ index, before, after }); - }); + const requested = collectWpmRequests(message as WpmEditMessage, doc.file.data.length); + if (!requested.length) return; + const changes = buildWpmRunChanges(doc.file.data, requested); if (!changes.length) return; const beforeRevision = doc.currentRevision; const afterRevision = doc.nextRevision++; - this.applyCells(doc, changes, false); + this.applyRuns(doc, changes, false); doc.currentRevision = afterRevision; this.postDirtyState(doc); + const changedCells = wpmRunCellCount(changes); this._onDidChange.fire({ document: doc, - label: `${changes.every((change) => change.after === 0) ? 'Erase' : 'Paint'} ${changes.length} pathing cell${changes.length === 1 ? '' : 's'}`, + label: `${changes.every((change) => change.after === 0) ? 'Erase' : 'Paint'} ${changedCells} pathing cell${changedCells === 1 ? '' : 's'}`, undo: () => { - this.applyCells(doc, changes, true); + this.applyRuns(doc, changes, true); doc.currentRevision = beforeRevision; this.postDirtyState(doc); }, redo: () => { - this.applyCells(doc, changes, false); + this.applyRuns(doc, changes, false); doc.currentRevision = afterRevision; this.postDirtyState(doc); }, }); } - private applyCells(doc: WpmDocument, changes: WpmCellChange[], useBefore: boolean): void { - const patches = changes.map((change) => { + private applyRuns(doc: WpmDocument, changes: WpmRunChange[], useBefore: boolean): void { + const patches: WpmRequestedRun[] = []; + for (const change of changes) { const value = useBefore ? change.before : change.after; - doc.file.data[change.index] = value; - return { index: change.index, value }; - }); - void doc.webview?.postMessage({ type: 'applyCells', changes: patches }); + for (let index = change.start; index < change.start + change.length; index++) doc.file.data[index] = value; + patches.push({ start: change.start, length: change.length, value }); + } + void doc.webview?.postMessage({ type: 'applyRuns', runs: patches }); } private postDirtyState(doc: WpmDocument): void {