← Files ModRetro Chromatic PluginARCHIVED FILE

dist/web-annotations/targets.js

11.5 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

const WIDTH = 160;
const HEIGHT = 144;
const hex = (value, width = 2) => value.toString(16).toUpperCase().padStart(width, "0");
function contains(rect, x, y) {
    return x >= rect.x && y >= rect.y && x < rect.x + rect.width && y < rect.y + rect.height;
}
export function toGamePoint(rect, x, y) {
    if (rect.width <= 0 || rect.height <= 0 || !contains(rect, x, y))
        return null;
    return {
        x: Math.floor(((x - rect.x) * WIDTH) / rect.width),
        y: Math.floor(((y - rect.y) * HEIGHT) / rect.height),
    };
}
export function viewportRect(rect, canvasRect) {
    return {
        x: canvasRect.x + (rect.x * canvasRect.width) / WIDTH,
        y: canvasRect.y + (rect.y * canvasRect.height) / HEIGHT,
        width: (rect.width * canvasRect.width) / WIDTH,
        height: (rect.height * canvasRect.height) / HEIGHT,
    };
}
function finishTarget(snapshot, target) {
    const counts = new Map();
    for (const pixel of target.pixels) {
        const offset = pixel * 4;
        const color = `#${hex(snapshot.rgba[offset])}${hex(snapshot.rgba[offset + 1])}${hex(snapshot.rgba[offset + 2])}`.toLowerCase();
        counts.set(color, (counts.get(color) ?? 0) + 1);
    }
    return {
        ...target,
        colors: [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([color]) => color),
    };
}
export function frameTarget(snapshot) {
    return {
        id: `${snapshot.id}:frame`,
        name: snapshot.rom,
        kind: "frame",
        rect: { x: 0, y: 0, width: WIDTH, height: HEIGHT },
        pixels: [],
        colors: [],
        details: `${snapshot.cgb ? "GBC" : "GB"} framebuffer`,
    };
}
function tilePixel(snapshot, tile, bank, x, y, signed = false) {
    const base = signed ? 0x1000 + (tile < 128 ? tile : tile - 256) * 16 : tile * 16;
    const data = snapshot.vram[bank === 1 ? 1 : 0];
    return ((data[base + y * 2] >> (7 - x)) & 1) | (((data[base + y * 2 + 1] >> (7 - x)) & 1) << 1);
}
/** Reconstructs snapshot hardware. Mid-frame raster changes and special priority modes remain approximate. */
export function hardwareTargets(snapshot) {
    if (!(snapshot.lcdc & 0x80))
        return [];
    const tiles = new Map();
    const bgIndices = new Uint8Array(WIDTH * HEIGHT);
    const bgPriority = new Uint8Array(WIDTH * HEIGHT);
    for (let y = 0; y < HEIGHT; y++) {
        for (let x = 0; x < WIDTH; x++) {
            if (!snapshot.cgb && !(snapshot.lcdc & 1))
                continue;
            const window = !!(snapshot.lcdc & 0x20) && y >= snapshot.wy && x >= snapshot.wx - 7 && snapshot.wx <= 166;
            const mx = window ? x - (snapshot.wx - 7) : (x + snapshot.scx) & 255;
            const my = window ? y - snapshot.wy : (y + snapshot.scy) & 255;
            const map = snapshot.lcdc & (window ? 0x40 : 0x08) ? 0x1c00 : 0x1800;
            const offset = map + (my >> 3) * 32 + (mx >> 3);
            const tile = snapshot.vram[0][offset];
            const attr = snapshot.cgb ? snapshot.vram[1][offset] : 0;
            bgIndices[y * WIDTH + x] = tilePixel(snapshot, tile, (attr >> 3) & 1, attr & 0x20 ? 7 - (mx & 7) : mx & 7, attr & 0x40 ? 7 - (my & 7) : my & 7, !(snapshot.lcdc & 0x10));
            bgPriority[y * WIDTH + x] = attr >> 7;
            const kind = window ? "window" : "background";
            const key = `${kind}:${hex(offset + 0x8000, 4)}`;
            let target = tiles.get(key);
            if (!target) {
                target = {
                    id: `${snapshot.id}:${key}`,
                    name: `${window ? "Window" : "Background"} tile ${hex(tile)}`,
                    kind,
                    rect: { x, y, width: 1, height: 1 },
                    pixels: [],
                    graphic: {
                        tile,
                        bank: (attr >> 3) & 1,
                        palette: attr & 7,
                        mapAddress: offset + 0x8000,
                        address: snapshot.lcdc & 0x10 ? 0x8000 + tile * 16 : 0x9000 + (tile < 128 ? tile : tile - 256) * 16,
                        flipX: !!(attr & 0x20),
                        flipY: !!(attr & 0x40),
                    },
                    details: `Map $${hex(offset + 0x8000, 4)}; tile $${hex(tile)}; VRAM bank ${(attr >> 3) & 1}; palette ${attr & 7}`,
                };
                tiles.set(key, target);
            }
            target.pixels.push(y * WIDTH + x);
            target.rect.width = Math.max(target.rect.width, x - target.rect.x + 1);
            target.rect.height = Math.max(target.rect.height, y - target.rect.y + 1);
        }
    }
    const sprites = [];
    const height = snapshot.lcdc & 4 ? 16 : 8;
    if (snapshot.lcdc & 2) {
        for (let index = 0; index < 40; index++) {
            const y = snapshot.oam[index * 4] - 16;
            const x = snapshot.oam[index * 4 + 1] - 8;
            if (x >= WIDTH || x + 8 <= 0 || y >= HEIGHT || y + height <= 0)
                continue;
            const tile = snapshot.oam[index * 4 + 2] & (height === 16 ? 254 : 255);
            const attr = snapshot.oam[index * 4 + 3];
            const pixels = [];
            for (let py = Math.max(y, 0); py < Math.min(y + height, HEIGHT); py++) {
                let eligible = 0;
                for (let earlier = 0; earlier <= index; earlier++) {
                    const sy = snapshot.oam[earlier * 4] - 16;
                    if (py >= sy && py < sy + height)
                        eligible++;
                }
                if (eligible > 10)
                    continue;
                for (let px = Math.max(x, 0); px < Math.min(x + 8, WIDTH); px++) {
                    let ty = py - y;
                    if (attr & 0x40)
                        ty = height - 1 - ty;
                    const color = tilePixel(snapshot, tile + (ty >> 3), snapshot.cgb ? (attr >> 3) & 1 : 0, attr & 0x20 ? 7 - (px - x) : px - x, ty & 7);
                    if (color)
                        pixels.push(py * WIDTH + px);
                }
            }
            sprites.push({
                id: `${snapshot.id}:oam:${index}`,
                name: `Sprite ${index}; tile ${hex(tile)}`,
                kind: "sprite",
                graphic: {
                    tile,
                    bank: snapshot.cgb ? (attr >> 3) & 1 : 0,
                    palette: snapshot.cgb ? attr & 7 : (attr >> 4) & 1,
                    address: 0x8000 + tile * 16,
                    oamIndex: index,
                    flipX: !!(attr & 0x20),
                    flipY: !!(attr & 0x40),
                },
                rect: {
                    x: Math.max(x, 0), y: Math.max(y, 0),
                    width: Math.min(x + 8, WIDTH) - Math.max(x, 0),
                    height: Math.min(y + height, HEIGHT) - Math.max(y, 0),
                },
                pixels,
                details: `OAM ${index} at $${hex(0xfe00 + index * 4, 4)}; tile $${hex(tile)}; bank ${snapshot.cgb ? (attr >> 3) & 1 : 0}; palette ${snapshot.cgb ? attr & 7 : (attr >> 4) & 1}`,
            });
        }
    }
    const ordered = snapshot.cgb ? sprites : [...sprites].sort((a, b) => {
        const ai = a.graphic.oamIndex;
        const bi = b.graphic.oamIndex;
        return snapshot.oam[ai * 4 + 1] - snapshot.oam[bi * 4 + 1] || ai - bi;
    });
    const occupied = new Set();
    for (const sprite of ordered) {
        const attr = snapshot.oam[sprite.graphic.oamIndex * 4 + 3];
        sprite.pixels = sprite.pixels.filter((pixel) => {
            if (occupied.has(pixel))
                return false;
            occupied.add(pixel);
            return !((snapshot.cgb ? !!(snapshot.lcdc & 1) : true) && bgIndices[pixel] && (attr & 0x80 || (snapshot.cgb && bgPriority[pixel])));
        });
    }
    const spritePixels = new Set(sprites.flatMap((target) => target.pixels));
    for (const target of tiles.values()) {
        target.hitPixels = target.pixels;
        target.pixels = target.pixels.filter((pixel) => !spritePixels.has(pixel));
    }
    return [...sprites.filter((target) => target.pixels.length), ...tiles.values()].map((target) => finishTarget(snapshot, target));
}
function bounds(targets) {
    const x = Math.min(...targets.map((target) => target.rect.x));
    const y = Math.min(...targets.map((target) => target.rect.y));
    return {
        x, y,
        width: Math.max(...targets.map((target) => target.rect.x + target.rect.width)) - x,
        height: Math.max(...targets.map((target) => target.rect.y + target.rect.height)) - y,
    };
}
function adjacent(a, b) {
    return ((a.x + a.width === b.x || b.x + b.width === a.x) && a.y === b.y)
        || ((a.y + a.height === b.y || b.y + b.height === a.y) && a.x === b.x);
}
/** Adjacent consecutive OAM entries are a grouping hint, never an authored object identity. */
function spriteGroups(snapshot, hardware) {
    const height = snapshot.lcdc & 4 ? 16 : 8;
    const sprites = hardware.filter((target) => target.kind === "sprite" && target.rect.width === 8 && target.rect.height === height);
    const byIndex = new Map(sprites.map((target) => [target.graphic.oamIndex, target]));
    const remaining = new Set(sprites);
    const groups = [];
    for (const first of sprites) {
        if (!remaining.delete(first))
            continue;
        const members = [first];
        const graphic = first.graphic;
        const priority = snapshot.oam[graphic.oamIndex * 4 + 3] & 0x80;
        for (let index = graphic.oamIndex + 1; members.length < 12; index++) {
            const next = byIndex.get(index);
            if (!next || !remaining.has(next) || next.graphic.bank !== graphic.bank || next.graphic.palette !== graphic.palette
                || (snapshot.oam[index * 4 + 3] & 0x80) !== priority || !members.some((member) => adjacent(member.rect, next.rect)))
                break;
            const rect = bounds([...members, next]);
            if (rect.width > 48 || rect.height > 48)
                break;
            members.push(next);
            remaining.delete(next);
        }
        if (members.length < 2)
            continue;
        const summary = "Inferred from adjacent consecutive OAM parts with shared bank, palette and priority; authored identity unknown.";
        groups.push(finishTarget(snapshot, {
            id: `${snapshot.id}:sprite-group:${graphic.oamIndex}`,
            name: `Inferred sprite group; ${members.length} parts`,
            kind: "sprite",
            rect: bounds(members),
            pixels: members.flatMap((target) => target.pixels),
            details: `Inferred group; OAM ${members.map((target) => target.graphic.oamIndex).join(", ")}; bank ${graphic.bank}; palette ${graphic.palette}`,
            evidence: { method: "oam-group", summary, members: members.map((target) => target.id) },
        }));
    }
    return groups;
}
export function annotationTargets(snapshot) {
    const hardware = hardwareTargets(snapshot);
    return [...spriteGroups(snapshot, hardware), ...hardware];
}
export function targetAt(targets, x, y) {
    if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT)
        return null;
    const pixel = y * WIDTH + x;
    return targets.find((target) => contains(target.rect, x, y) && (target.hitPixels ?? target.pixels).includes(pixel)) ?? null;
}
/** Two compact context fields; the native target ID already carries capture identity. */
export function metadata(snapshot, target) {
    const source = snapshot.sourceRevision ? `; source ${snapshot.sourceRevision}` : "";
    return {
        Build: `ROM SHA-256 ${snapshot.sha256}; frame ${snapshot.frame}${source}`.slice(0, 256),
        Hardware: `${target.rect.x},${target.rect.y} ${target.rect.width}x${target.rect.height}px; ${target.details}`.slice(0, 256),
    };
}
//# sourceMappingURL=targets.js.map

SHA-256: a7f6cdd8af5b24fbfe88967defa5116ee50ceef6fdbbb764723cbc14a1601ca9