Skip to content

Commit d30a669

Browse files
committed
Add hardware simulations and redesign filter finder
Implements docs/plan/067-hardware-encoding-simulations.md, docs/plan/068-filter-typeahead-redesign.md, and docs/plan/069-uncommitted-batch-hardening.md.
1 parent fa82048 commit d30a669

21 files changed

Lines changed: 2294 additions & 315 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# 067 — Hardware encoding simulations
2+
3+
## Objective
4+
5+
Add four historically specific image/video simulations whose recognizable
6+
artifacts follow the original storage or display hardware rather than a generic
7+
retro grade:
8+
9+
1. Apple II high-resolution NTSC artifact color.
10+
2. ZX Spectrum 48K bitmap and attribute cells.
11+
3. Amiga OCS six-bit-plane Hold-And-Modify (HAM6).
12+
4. Fisher-Price PXL-2000 cassette video.
13+
14+
The filters keep their hardware constraints visible in both their controls and
15+
their output. Conversion from an arbitrary source image is deterministic, while
16+
the decoded/displayed result obeys the relevant pixel, palette, scanline, and
17+
temporal contracts.
18+
19+
## Specification basis
20+
21+
- Apple IIe Technical Reference Manual: 280×192 HGR dots; seven displayed bits
22+
per byte; bit 7 selects purple/green or blue/orange; an isolated set dot is an
23+
artifact color determined by column phase, while adjacent set dots are white;
24+
the 7 MHz dot stream is one half-cycle of the 3.58 MHz NTSC subcarrier.
25+
- Sinclair ZX Spectrum user manual: a 256×192 one-bit bitmap plus one attribute
26+
byte per 8×8 character cell; each cell has one INK, PAPER, BRIGHT, and FLASH
27+
state and therefore no more than two colors in its 64 dots.
28+
- Commodore Amiga Hardware Reference Manual: HAM uses six bitplanes; `00`
29+
selects one of sixteen 12-bit color registers, while `01`, `10`, and `11`
30+
retain the previous pixel and replace its blue, red, or green nibble.
31+
- US Patent 4,875,107: PXL-2000 architecture with a 120×90 monochrome frame
32+
transfer CCD, 15 Hz acquisition, 90 kHz low-pass filter, 180 kHz pixel system,
33+
FM storage on an audio cassette running about eight times normal speed, and
34+
dual-frame ping-pong playback to standard television timing.
35+
36+
Primary documents:
37+
38+
- <https://manuals.plus/m/4843507d8de925f2d23dda507f319a943f2f8d39db89e52ef3dd76f1d2e1b305>
39+
- <https://manualzz.com/doc/23592820/sinclair-zx-spectrum-user-manual>
40+
- <https://oldcrap.org/wp-content/uploads/2023/04/amiga-all-hw-ref-manual.pdf>
41+
- <https://patents.google.com/patent/US4875107A/en>
42+
43+
## Implementation
44+
45+
1. Add pure codec helpers for Apple HGR dot decoding, ZX attribute selection,
46+
HAM6 scanline encoding/decoding, and PXL capture timing.
47+
2. Add Apple II HGR as a WebGL2-only gather filter. Convert to the 280×192 dot
48+
grid, choose one color-set bit per seven dots, apply the documented neighbor
49+
and column-phase color rules, and expose color/green/monochrome monitors.
50+
3. Add ZX Spectrum as a two-pass WebGL2 filter. First choose one legal attribute
51+
and bitmap assignment per 8×8 cell; then reconstruct the 256×192 display,
52+
including deterministic hardware-rate FLASH swapping.
53+
4. Add Amiga HAM6 as a sequential CPU filter. Build sixteen 12-bit base color
54+
registers, encode each low-resolution scanline with legal direct/modify
55+
opcodes, reset from COLOR00 at each line, and scale the decoded raster back to
56+
the input canvas.
57+
5. Add PXL-2000 as a temporal WebGL2 filter with the 120×90 CCD grid, monochrome
58+
integration and clipping, bandwidth loss, FM/cassette noise and dropouts,
59+
and 15 Hz ping-pong frame holding.
60+
6. Register all filters, regenerate selective exports/catalog metadata, and add
61+
focused browser signal-property checks in addition to the registry-wide GL
62+
compile/draw sweep.
63+
64+
## Acceptance gates
65+
66+
- Apple HGR emits black for clear dots, white for adjacent set dots, and the
67+
documented even/odd artifact color for isolated set dots and each byte phase.
68+
- Every ZX 8×8 cell contains no more than two colors from a single brightness
69+
bank; FLASH swaps those colors without changing the bitmap.
70+
- Every HAM6 opcode decodes according to the Commodore bitplane table, scanlines
71+
reset from COLOR00, and all output components lie on the OCS 4-bit ladder.
72+
- PXL output uses a 120×90 sampling grid and holds intermediate preview frames
73+
according to the exact 15 Hz capture ratio.
74+
- Legacy/malformed options remain finite and deterministic.
75+
- `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, generated
76+
catalog checks, and `npm run test:gl` pass without new warnings.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# 068 — Filter finder redesign
2+
3+
## Problem
4+
5+
The compact filter typeahead exposes more than 300 filters as a mostly flat,
6+
bounded list. Its empty state mixes recents with arbitrary category examples,
7+
capability labels are terse, and arrow keys on a closed replacement control can
8+
mutate the active chain before the user opens or confirms a choice. The result
9+
requires recall, creates accidental-action risk, and makes browsing difficult.
10+
11+
## Design direction
12+
13+
Treat the control as a compact **filter finder** that fits Ditherer's desktop
14+
workbench aesthetic. Preserve fast type-to-search behavior while adding
15+
progressive disclosure and an explicit information scent:
16+
17+
1. State whether the action will add a stage or replace a named stage.
18+
2. Keep global search first and focused on open.
19+
3. Make the no-query state a browse landing page with recent filters and
20+
category cards showing catalog counts and representative examples.
21+
4. Once searching or browsing a category, use a stable two-pane layout: a
22+
scannable result list and a selected-result explanation with full
23+
description, category, control count, and plain-language capabilities.
24+
5. Commit only on click or Enter. Arrow keys may navigate inside the open list,
25+
but must never replace a filter while the chooser is closed.
26+
6. Retain a compact single-column mobile layout with 44 px minimum targets,
27+
bounded height, readable type, and no viewport overflow.
28+
29+
## Cognitive and interaction principles
30+
31+
- Prefer recognition over recall through named categories and examples.
32+
- Use progressive disclosure: category overview first, detailed list second.
33+
- Keep selection and activation distinct so exploration is reversible.
34+
- Reduce visual competition in result rows; move explanations into a stable
35+
detail pane rather than repeating tiny truncated descriptions.
36+
- Preserve spatial stability while keyboarding so focus and explanatory text
37+
change without moving the list or applying side effects.
38+
- Expose system status continuously: result count, active scope, total catalog
39+
size, current filter, and available keyboard actions.
40+
41+
## Implementation
42+
43+
1. Refactor `FilterCombobox.tsx` around explicit landing, category, search, and
44+
empty states.
45+
2. Remove preview-on-arrow replacement behavior from the closed trigger and its
46+
ChainList integration.
47+
3. Rework the CSS module into a responsive two-pane finder that remains native
48+
to the existing Ditherer chrome.
49+
4. Expand component and Playwright coverage for safe keyboard behavior,
50+
category browsing, search ranking, recents, viewport bounds, and selection.
51+
5. Validate visually at desktop and compact widths, then run lint, typecheck,
52+
focused tests, the production build, and the repository check command.
53+
54+
## Acceptance criteria
55+
56+
- Opening without a query explains the add/replace action and exposes every
57+
filter category with a count.
58+
- Users can reach a category's filters without inventing a search term.
59+
- Search remains global, ranked, bounded, highlighted, and keyboard operable.
60+
- Arrow keys on a closed trigger do not mutate the chain.
61+
- Click and Enter commit exactly one filter and update recents.
62+
- The selected result has a readable, stable explanation before commitment.
63+
- Desktop and compact layouts remain inside the viewport with accessible
64+
labels, focus, status announcements, and touch targets.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# 069 — Uncommitted Batch Hardening
2+
3+
## Scope
4+
5+
Review and harden the uncommitted hardware-simulation filters and filter-finder
6+
redesign. Preserve unrelated existing work and stop only after a complete review
7+
pass produces no actionable findings.
8+
9+
## Review loops
10+
11+
1. **Simulation contracts and backends**
12+
- Audit filter metadata, registry/loader generation, temporal declarations,
13+
WebGL availability behavior, option sanitization, and resource lifetimes.
14+
- Compare shader behavior with the pure codec contracts and add focused
15+
regressions for any uncovered invariant.
16+
2. **Algorithm and edge-case correctness**
17+
- Exercise invalid options, tiny and extreme aspect-ratio canvases, temporal
18+
warm-up/hold behavior, palette degeneracy, and deterministic output.
19+
- Run the real-browser GL release gate and the focused codec suite.
20+
3. **Finder interaction, IA, and accessibility**
21+
- Review add versus replace semantics, keyboard-only use, focus return,
22+
category/search transitions, empty states, recent-item handling, and mobile
23+
layout.
24+
- Convert any reproduced issue into a durable component or browser test.
25+
4. **Independent clean pass**
26+
- Re-read the final diff without relying on the earlier findings, run static
27+
and full-project gates, and record a clean pass only when no new issue is
28+
found.
29+
30+
## Verification gates
31+
32+
- `npm run typecheck`
33+
- `npm run lint`
34+
- `npm test -- --run`
35+
- `npm run build`
36+
- `npm run test:gl`
37+
- affected Playwright workbench suite
38+
- `git diff --check`
39+
40+
## Outcome
41+
42+
Round one found and resolved:
43+
44+
- ZX Spectrum FLASH was running at half the ULA cadence. It now swaps every
45+
16 hardware frames (a 32-frame / approximately 0.64-second full cycle), with
46+
a pure timing contract.
47+
- Inline filter replacement was mouse-only. Stage names are now real buttons,
48+
expose a replacement label, and restore focus after Escape or selection.
49+
- Arrow-key opening skipped the normal shared-recents refresh and did not
50+
expose a stable popup relationship. Keyboard and pointer opening now share
51+
one initialization path and `aria-controls` targets the finder dialog.
52+
- Category browsing was incorrectly labelled as globally ranked search. Its
53+
A–Z ordering is now explicit.
54+
55+
Round two found no additional simulation, registry, shader, temporal-history,
56+
finder, mobile-layout, or accessibility issues. The independent final diff and
57+
verification pass also produced no new findings.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { ENUM, RANGE } from "../constants/controlTypes";
2+
import { cloneCanvas, logFilterBackend, releasePooledCanvas, takePooledCanvas } from "../utils/index";
3+
import { encodeHam6Scanline } from "./retroHardwareCodecs";
4+
import { defineFilter, type FilterCanvas, type FilterOptionValues } from "./types";
5+
6+
const STANDARD_NTSC = "NTSC";
7+
const STANDARD_PAL = "PAL";
8+
const PALETTE_ADAPTIVE = "ADAPTIVE";
9+
const PALETTE_OCS = "OCS";
10+
11+
export const optionTypes = {
12+
standard: {
13+
type: ENUM,
14+
options: [
15+
{ name: "NTSC 320×200", value: STANDARD_NTSC },
16+
{ name: "PAL 320×256", value: STANDARD_PAL },
17+
],
18+
default: STANDARD_NTSC,
19+
desc: "Nominal low-resolution Amiga display geometry used for HAM6 encoding",
20+
},
21+
paletteMode: {
22+
type: ENUM,
23+
options: [
24+
{ name: "Adaptive 16 registers", value: PALETTE_ADAPTIVE },
25+
{ name: "OCS demonstration palette", value: PALETTE_OCS },
26+
],
27+
default: PALETTE_ADAPTIVE,
28+
desc: "Six-plane HAM reserves direct opcodes for 16 base color registers; adaptive mode derives them from the image",
29+
},
30+
paletteIterations: { type: RANGE, range: [1, 12], step: 1, default: 6, desc: "Deterministic clustering passes used to fill the 16 direct-color registers" },
31+
};
32+
33+
export const defaults = {
34+
standard: optionTypes.standard.default,
35+
paletteMode: optionTypes.paletteMode.default,
36+
paletteIterations: optionTypes.paletteIterations.default,
37+
};
38+
39+
type Ham6Options = FilterOptionValues & Partial<typeof defaults>;
40+
41+
const OCS_PALETTE = [
42+
0x000, 0xfff, 0x05a, 0xf80, 0x08f, 0x0c0, 0xf00, 0x0dd,
43+
0x06f, 0x0a0, 0x608, 0xc30, 0x777, 0xaaa, 0xddd, 0x444,
44+
];
45+
46+
const packedPalette = (colors: number[]): Uint8Array => Uint8Array.from(colors.flatMap((color) => [
47+
(color >>> 8 & 15) * 17,
48+
(color >>> 4 & 15) * 17,
49+
(color & 15) * 17,
50+
255,
51+
]));
52+
53+
const quantize4 = (value: number): number => Math.max(0, Math.min(255, Math.round(value / 17) * 17));
54+
55+
export const buildHam6Palette = (pixels: Uint8ClampedArray, iterations = 6): Uint8Array => {
56+
const count = Math.floor(pixels.length / 4);
57+
if (count < 1) return packedPalette(OCS_PALETTE);
58+
const stride = Math.max(1, Math.floor(count / 4096));
59+
const samples: number[][] = [];
60+
for (let pixel = 0; pixel < count; pixel += stride) {
61+
const offset = pixel * 4;
62+
samples.push([pixels[offset] ?? 0, pixels[offset + 1] ?? 0, pixels[offset + 2] ?? 0]);
63+
}
64+
samples.sort((a, b) => (a[0]! * 3 + a[1]! * 6 + a[2]!) - (b[0]! * 3 + b[1]! * 6 + b[2]!));
65+
const centroids = Array.from({ length: 16 }, (_, index) => {
66+
const sample = samples[Math.min(samples.length - 1, Math.floor((index + 0.5) * samples.length / 16))]!;
67+
return [...sample];
68+
});
69+
const passes = Math.max(1, Math.min(12, Math.round(iterations)));
70+
for (let pass = 0; pass < passes; pass++) {
71+
const sums = Array.from({ length: 16 }, () => [0, 0, 0, 0]);
72+
for (const sample of samples) {
73+
let best = 0;
74+
let bestDistance = Number.POSITIVE_INFINITY;
75+
for (let index = 0; index < centroids.length; index++) {
76+
const center = centroids[index]!;
77+
const dr = sample[0]! - center[0]!;
78+
const dg = sample[1]! - center[1]!;
79+
const db = sample[2]! - center[2]!;
80+
const distance = dr * dr + dg * dg + db * db;
81+
if (distance < bestDistance) {
82+
bestDistance = distance;
83+
best = index;
84+
}
85+
}
86+
const sum = sums[best]!;
87+
sum[0]! += sample[0]!;
88+
sum[1]! += sample[1]!;
89+
sum[2]! += sample[2]!;
90+
sum[3]! += 1;
91+
}
92+
for (let index = 0; index < centroids.length; index++) {
93+
const sum = sums[index]!;
94+
if (sum[3]! > 0) centroids[index] = [sum[0]! / sum[3]!, sum[1]! / sum[3]!, sum[2]! / sum[3]!];
95+
}
96+
}
97+
centroids.sort((a, b) => (a[0]! * 3 + a[1]! * 6 + a[2]!) - (b[0]! * 3 + b[1]! * 6 + b[2]!));
98+
return Uint8Array.from(centroids.flatMap((color) => [
99+
quantize4(color[0]!), quantize4(color[1]!), quantize4(color[2]!), 255,
100+
]));
101+
};
102+
103+
const amigaHam6 = (input: FilterCanvas, options: Ham6Options = defaults): FilterCanvas => {
104+
if (input.width < 1 || input.height < 1) return input;
105+
const width = 320;
106+
const height = options.standard === STANDARD_PAL ? 256 : 200;
107+
const reduced = takePooledCanvas(width, height);
108+
const reducedContext = reduced.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
109+
if (!reducedContext) {
110+
releasePooledCanvas(reduced);
111+
return input;
112+
}
113+
reducedContext.imageSmoothingEnabled = true;
114+
reducedContext.drawImage(input as CanvasImageSource, 0, 0, width, height);
115+
const image = reducedContext.getImageData(0, 0, width, height);
116+
const iterations = Math.max(1, Math.min(12, Math.round(Number(options.paletteIterations) || defaults.paletteIterations)));
117+
const palette = options.paletteMode === PALETTE_OCS
118+
? packedPalette(OCS_PALETTE)
119+
: buildHam6Palette(image.data, iterations);
120+
121+
const row = new Uint8Array(width * 3);
122+
for (let y = 0; y < height; y++) {
123+
for (let x = 0; x < width; x++) {
124+
const sourceOffset = (y * width + x) * 4;
125+
const rowOffset = x * 3;
126+
row[rowOffset] = image.data[sourceOffset] ?? 0;
127+
row[rowOffset + 1] = image.data[sourceOffset + 1] ?? 0;
128+
row[rowOffset + 2] = image.data[sourceOffset + 2] ?? 0;
129+
}
130+
const encoded = encodeHam6Scanline(row, palette);
131+
image.data.set(encoded.output, y * width * 4);
132+
}
133+
reducedContext.putImageData(image, 0, 0);
134+
135+
const output = cloneCanvas(input, false);
136+
const outputContext = output.getContext("2d");
137+
if (!outputContext) {
138+
releasePooledCanvas(reduced);
139+
return input;
140+
}
141+
outputContext.imageSmoothingEnabled = false;
142+
outputContext.drawImage(reduced, 0, 0, output.width, output.height);
143+
releasePooledCanvas(reduced);
144+
logFilterBackend("Amiga HAM6", "JavaScript", `${width}x${height} sequential hold-and-modify scanlines`);
145+
return output;
146+
};
147+
148+
export default defineFilter({
149+
name: "Amiga HAM6",
150+
func: amigaHam6,
151+
optionTypes,
152+
defaults,
153+
options: defaults,
154+
description: "Amiga OCS six-plane hold-and-modify encoding with legal direct, red, green, and blue opcodes",
155+
noGL: "HAM6 output is a left-to-right state machine: each modify opcode changes the color held by the next pixel",
156+
});

0 commit comments

Comments
 (0)