This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -0,0 +1,245 @@
|
||||
import { RomReader } from "./reader";
|
||||
import { decompress } from "./lz";
|
||||
import { NATIVE_MAPS, SYMBOLS } from "./profile";
|
||||
import type {
|
||||
Direction,
|
||||
GameMap,
|
||||
Tileset,
|
||||
Palette,
|
||||
Sprite,
|
||||
FacingTile,
|
||||
} from "../world/types";
|
||||
|
||||
/** The only layer that knows where Crystal stores its data. Everything is
|
||||
* decoded from the supplied ROM, on demand, and cached only in memory. */
|
||||
export class CrystalAssets {
|
||||
readonly reader: RomReader;
|
||||
private maps = new Map<string, GameMap>();
|
||||
private tilesets = new Map<string, Tileset>();
|
||||
private sprites = new Map<number, Sprite>();
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.reader = new RomReader(bytes);
|
||||
}
|
||||
|
||||
map(id: string): GameMap {
|
||||
const cached = this.maps.get(id);
|
||||
if (cached) return cached;
|
||||
if (!Object.hasOwn(NATIVE_MAPS, id))
|
||||
throw new Error("This area has not been ported yet.");
|
||||
const [group, number] = id.split(":").map(Number);
|
||||
const r = this.reader;
|
||||
const header =
|
||||
r.near(SYMBOLS.mapGroups + (group - 1) * 2, r.bank(SYMBOLS.mapGroups)) +
|
||||
(number - 1) * 9;
|
||||
const attributes = r.near(header + 3, r.byte(header));
|
||||
const blockWidth = r.byte(attributes + 2),
|
||||
blockHeight = r.byte(attributes + 1);
|
||||
if (!blockWidth || !blockHeight || blockWidth > 64 || blockHeight > 64)
|
||||
throw new Error("Invalid map dimensions.");
|
||||
const scriptBank = r.byte(attributes + 6);
|
||||
let p = r.near(attributes + 9, scriptBank) + 2;
|
||||
const map: GameMap = {
|
||||
id,
|
||||
group,
|
||||
number,
|
||||
name: NATIVE_MAPS[id],
|
||||
width: blockWidth * 2,
|
||||
height: blockHeight * 2,
|
||||
border: r.byte(attributes),
|
||||
tileset: r.byte(header + 1),
|
||||
environment: r.byte(header + 2),
|
||||
paletteMode: r.byte(header + 7) & 15,
|
||||
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
|
||||
warps: [],
|
||||
backgroundEvents: [],
|
||||
objects: [],
|
||||
connections: [],
|
||||
sceneScripts: [],
|
||||
callbacks: [],
|
||||
coordinateEvents: [],
|
||||
};
|
||||
const warpCount = r.byte(p++);
|
||||
for (let i = 0; i < warpCount; i++, p += 5) {
|
||||
map.warps.push({
|
||||
y: r.byte(p),
|
||||
x: r.byte(p + 1),
|
||||
targetWarp: r.byte(p + 2),
|
||||
targetMap: `${r.byte(p + 3)}:${r.byte(p + 4)}`,
|
||||
});
|
||||
}
|
||||
const coordinateCount = r.byte(p++);
|
||||
for (let i = 0; i < coordinateCount; i++, p += 8) {
|
||||
map.coordinateEvents.push({
|
||||
scene: r.byte(p),
|
||||
y: r.byte(p + 1),
|
||||
x: r.byte(p + 2),
|
||||
script: r.near(p + 4, scriptBank),
|
||||
});
|
||||
}
|
||||
const backgroundCount = r.byte(p++);
|
||||
for (let i = 0; i < backgroundCount; i++, p += 5) {
|
||||
map.backgroundEvents.push({
|
||||
y: r.byte(p),
|
||||
x: r.byte(p + 1),
|
||||
kind: r.byte(p + 2),
|
||||
script: r.near(p + 3, scriptBank),
|
||||
});
|
||||
}
|
||||
const objectCount = r.byte(p++);
|
||||
for (let i = 0; i < objectCount; i++, p += 13) {
|
||||
map.objects.push({
|
||||
id: i + 2,
|
||||
sprite: r.byte(p),
|
||||
y: r.byte(p + 1) - 4,
|
||||
x: r.byte(p + 2) - 4,
|
||||
movement: r.byte(p + 3),
|
||||
hour1: r.byte(p + 5),
|
||||
hour2: r.byte(p + 6),
|
||||
palette: r.byte(p + 7) >> 4,
|
||||
kind: r.byte(p + 7) & 15,
|
||||
script: r.near(p + 9, scriptBank),
|
||||
eventFlag: r.word(p + 11),
|
||||
});
|
||||
}
|
||||
p = r.near(attributes + 7, scriptBank);
|
||||
const sceneCount = r.byte(p++);
|
||||
for (let i = 0; i < sceneCount; i++, p += 4)
|
||||
map.sceneScripts.push(r.near(p, scriptBank));
|
||||
const callbackCount = r.byte(p++);
|
||||
for (let i = 0; i < callbackCount; i++, p += 3)
|
||||
map.callbacks.push({
|
||||
kind: r.byte(p),
|
||||
script: r.near(p + 1, scriptBank),
|
||||
});
|
||||
p = attributes + 12;
|
||||
for (const [direction, bit] of [
|
||||
["up", 8],
|
||||
["down", 4],
|
||||
["left", 2],
|
||||
["right", 1],
|
||||
] as const) {
|
||||
if (!(r.byte(attributes + 11) & bit)) continue;
|
||||
const signed = (v: number) => (v > 127 ? v - 256 : v);
|
||||
map.connections.push({
|
||||
direction,
|
||||
targetMap: `${r.byte(p)}:${r.byte(p + 1)}`,
|
||||
y: signed(r.byte(p + 8)),
|
||||
x: signed(r.byte(p + 9)),
|
||||
});
|
||||
p += 12;
|
||||
}
|
||||
this.maps.set(id, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
tileset(map: GameMap): Tileset {
|
||||
const key = `${map.tileset}:${map.group}`;
|
||||
const cached = this.tilesets.get(key);
|
||||
if (cached) return cached;
|
||||
const r = this.reader,
|
||||
base = SYMBOLS.tilesets + map.tileset * 15;
|
||||
const graphics = decompress(r, r.far(base), 0x4000);
|
||||
const meta = r.far(base + 3),
|
||||
collision = r.far(base + 6);
|
||||
// Each tileset contains 128 metatiles (4x4 8px tiles), each with four
|
||||
// quadrant permissions for the 16px movement grid.
|
||||
const result: Tileset = {
|
||||
graphics,
|
||||
metatiles: r.slice(meta, 128 * 16),
|
||||
collision: r.slice(collision, 128 * 4),
|
||||
paletteMap: r.slice(r.near(base + 13, r.bank(SYMBOLS.tilesets)), 128),
|
||||
};
|
||||
if ([1, 2, 4].includes(map.tileset)) {
|
||||
const roof = r.byte(SYMBOLS.roofTypes + map.group);
|
||||
if (roof !== 255)
|
||||
result.graphics.set(
|
||||
r.slice(SYMBOLS.roofs + roof * 9 * 16, 9 * 16),
|
||||
0x0a * 16,
|
||||
);
|
||||
}
|
||||
this.tilesets.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
block(map: GameMap, x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return map.border;
|
||||
return map.blocks[Math.floor(y / 2) * (map.width / 2) + Math.floor(x / 2)];
|
||||
}
|
||||
collision(map: GameMap, x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
|
||||
const block = this.block(map, x, y);
|
||||
if (block >= 128) throw new Error("Map contains an invalid block.");
|
||||
return this.tileset(map).collision[block * 4 + (y % 2) * 2 + (x % 2)];
|
||||
}
|
||||
permission(collision: number): number {
|
||||
return this.reader.byte(SYMBOLS.collisions + collision) & 15;
|
||||
}
|
||||
|
||||
palette(offset: number): Palette {
|
||||
return Array.from({ length: 4 }, (_, i) => {
|
||||
const color = this.reader.word(offset + i * 2);
|
||||
return [
|
||||
Math.round(((color & 31) * 255) / 31),
|
||||
Math.round((((color >> 5) & 31) * 255) / 31),
|
||||
Math.round((((color >> 10) & 31) * 255) / 31),
|
||||
];
|
||||
});
|
||||
}
|
||||
palettes(map: GameMap, time: number): Palette[] {
|
||||
const r = this.reader;
|
||||
const group =
|
||||
r.near(
|
||||
SYMBOLS.environmentColors + map.environment * 2,
|
||||
r.bank(SYMBOLS.environmentColors),
|
||||
) +
|
||||
time * 8;
|
||||
const palettes = Array.from({ length: 8 }, (_, i) =>
|
||||
this.palette(
|
||||
map.tileset === 5
|
||||
? SYMBOLS.housePalettes + i * 8
|
||||
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
|
||||
),
|
||||
);
|
||||
if (map.environment === 1 || map.environment === 2) {
|
||||
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
|
||||
const colors = this.palette(roof);
|
||||
palettes[6] = [palettes[6][0], colors[0], colors[1], palettes[6][3]];
|
||||
}
|
||||
return palettes;
|
||||
}
|
||||
objectPalette(index: number, time: number): Palette {
|
||||
return this.palette(SYMBOLS.objectPalettes + time * 64 + (index & 7) * 8);
|
||||
}
|
||||
sprite(id: number): Sprite | undefined {
|
||||
// Variable decorations and Pokémon icons need separate decoders.
|
||||
if (id < 1 || id >= 0xa0) return undefined;
|
||||
const cached = this.sprites.get(id);
|
||||
if (cached) return cached;
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.sprites + (id - 1) * 6;
|
||||
const length = r.byte(p + 2),
|
||||
walking = r.byte(p + 4) === 1;
|
||||
const sprite = {
|
||||
graphics: r.slice(r.near(p, r.byte(p + 3)), length * (walking ? 2 : 1)),
|
||||
palette: r.byte(p + 5),
|
||||
walking,
|
||||
};
|
||||
this.sprites.set(id, sprite);
|
||||
return sprite;
|
||||
}
|
||||
facing(direction: Direction, frame: number): FacingTile[] {
|
||||
const facing =
|
||||
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4);
|
||||
const r = this.reader,
|
||||
pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings));
|
||||
return Array.from({ length: r.byte(pointer) }, (_, i) => {
|
||||
const p = pointer + 1 + i * 4;
|
||||
return {
|
||||
y: r.byte(p),
|
||||
x: r.byte(p + 1),
|
||||
attributes: r.byte(p + 2),
|
||||
tile: r.byte(p + 3),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RomReader } from "./reader";
|
||||
|
||||
/** Native translation of Crystal's LZ data format (home/decompress.asm).
|
||||
* Copy commands may overlap their own output. Addresses are relative to the
|
||||
* decompressed stream, never arbitrary memory. */
|
||||
export function decompress(
|
||||
reader: RomReader,
|
||||
offset: number,
|
||||
limit = 0x10000,
|
||||
): Uint8Array {
|
||||
const output: number[] = [];
|
||||
let cursor = offset;
|
||||
const read = () => reader.byte(cursor++);
|
||||
for (let commandCount = 0; commandCount < limit; commandCount++) {
|
||||
const header = read();
|
||||
if (header === 0xff) return Uint8Array.from(output);
|
||||
let command = header >> 5;
|
||||
let length = (header & 31) + 1;
|
||||
if (command === 7) {
|
||||
command = (header >> 2) & 7;
|
||||
length = (((header & 3) << 8) | read()) + 1;
|
||||
}
|
||||
if (command === 7 || output.length + length > limit)
|
||||
throw new Error("Invalid or oversized compressed graphic.");
|
||||
if (command === 0) {
|
||||
for (let i = 0; i < length; i++) output.push(read());
|
||||
} else if (command === 1) {
|
||||
const value = read();
|
||||
for (let i = 0; i < length; i++) output.push(value);
|
||||
} else if (command === 2) {
|
||||
const a = read(),
|
||||
b = read();
|
||||
for (let i = 0; i < length; i++) output.push(i % 2 ? b : a);
|
||||
} else if (command === 3) {
|
||||
for (let i = 0; i < length; i++) output.push(0);
|
||||
} else {
|
||||
const first = read();
|
||||
let source =
|
||||
first & 0x80
|
||||
? output.length - (first & 0x7f) - 1
|
||||
: (first << 8) | read();
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (source < 0 || source >= output.length)
|
||||
throw new Error("Invalid compressed graphic back-reference.");
|
||||
let value = output[source];
|
||||
if (command === 5) {
|
||||
value = ((value & 0x55) << 1) | ((value >> 1) & 0x55);
|
||||
value = ((value & 0x33) << 2) | ((value >> 2) & 0x33);
|
||||
value = ((value & 0x0f) << 4) | (value >> 4);
|
||||
}
|
||||
output.push(value);
|
||||
source += command === 6 ? -1 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Compressed graphic has no terminator.");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Addresses only, from PRET 7a7881d0d62e0ddbd82dcf10e7116807487ac651.
|
||||
* RGBDS 1.0.3 crystal11 build was byte-identical to the user-supplied ROM.
|
||||
* No original graphics, maps, dialogue, music or ROM instructions live here.
|
||||
* See docs/NATIVE_PORT.md for source references and verification procedure. */
|
||||
export const SYMBOLS = {
|
||||
mapGroups: 0x94000,
|
||||
tilesets: 0x4d596,
|
||||
collisions: 0x4ce1f,
|
||||
environmentColors: 0xb279,
|
||||
backgroundPalettes: 0xb319,
|
||||
objectPalettes: 0xb469,
|
||||
roofPalettes: 0xb569,
|
||||
roofTypes: 0x1c021,
|
||||
roofs: 0x1c03c,
|
||||
housePalettes: 0x495ee,
|
||||
sprites: 0x14736,
|
||||
facings: 0x4049,
|
||||
standardScripts: 0xbc000,
|
||||
} as const;
|
||||
|
||||
/** The currently ported area. Story callbacks are not executed yet. */
|
||||
export const NATIVE_MAPS: Record<string, string> = {
|
||||
"24:4": "New Bark Town",
|
||||
"24:5": "Elm’s lab",
|
||||
"24:6": "Your home",
|
||||
"24:7": "Your room",
|
||||
"24:8": "Neighbor’s house",
|
||||
"24:9": "Elm’s house",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Bounds-checked access to the user's cartridge. No CPU or hardware state. */
|
||||
export class RomReader {
|
||||
constructor(readonly bytes: Uint8Array) {}
|
||||
byte(offset: number): number {
|
||||
this.check(offset, 1);
|
||||
return this.bytes[offset];
|
||||
}
|
||||
word(offset: number): number {
|
||||
return this.byte(offset) | (this.byte(offset + 1) << 8);
|
||||
}
|
||||
slice(offset: number, length: number): Uint8Array {
|
||||
this.check(offset, length);
|
||||
return this.bytes.slice(offset, offset + length);
|
||||
}
|
||||
address(bank: number, address: number): number {
|
||||
if (
|
||||
!Number.isInteger(bank) ||
|
||||
bank < 0 ||
|
||||
bank >= this.bytes.length / 0x4000 ||
|
||||
!Number.isInteger(address) ||
|
||||
address < 0 ||
|
||||
address >= 0x8000
|
||||
) {
|
||||
throw new Error("Invalid ROM bank or address.");
|
||||
}
|
||||
const offset =
|
||||
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||
this.check(offset, 1);
|
||||
return offset;
|
||||
}
|
||||
near(pointer: number, bank: number): number {
|
||||
return this.address(bank, this.word(pointer));
|
||||
}
|
||||
far(pointer: number): number {
|
||||
return this.near(pointer + 1, this.byte(pointer));
|
||||
}
|
||||
bank(offset: number): number {
|
||||
this.check(offset, 1);
|
||||
return Math.floor(offset / 0x4000);
|
||||
}
|
||||
private check(offset: number, length: number) {
|
||||
if (
|
||||
!Number.isInteger(offset) ||
|
||||
!Number.isInteger(length) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
offset + length > this.bytes.length
|
||||
) {
|
||||
throw new Error(
|
||||
"Cartridge data is truncated or contains an invalid pointer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user