This commit is contained in:
1 parent
6a74f59e46
commit
3e92936cb9
20 files changed
+1417
-32
No files matched your search
@@ -167,6 +167,7 @@ export class CrystalAssets {
|
||||
x: r.byte(p + 2) - 4,
|
||||
movement: r.byte(p + 3),
|
||||
radius: r.byte(p + 4),
|
||||
sightRange: r.byte(p + 8),
|
||||
hour1: r.byte(p + 5),
|
||||
hour2: r.byte(p + 6),
|
||||
palette: r.byte(p + 7) >> 4,
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
* 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 = {
|
||||
trainerGroups: 0x39999,
|
||||
trainerClassNames: 0x2c1ef,
|
||||
trainerClassAttributes: 0x3959c,
|
||||
trainerClassDVs: 0x270d6,
|
||||
statusOnlyEffects: 0x385db,
|
||||
residualMoves: 0x39446,
|
||||
mapGroups: 0x94000,
|
||||
tilesets: 0x4d596,
|
||||
collisions: 0x4ce1f,
|
||||
@@ -104,6 +110,7 @@ export const SPECIAL_ROUTINES = {
|
||||
|
||||
/** Work RAM addresses that the event and text formats name as operands. */
|
||||
export const RAM = {
|
||||
battleReward: 0xc686,
|
||||
stringBuffer1: 0xd073,
|
||||
stringBuffer2: 0xd086,
|
||||
stringBuffer3: 0xd099,
|
||||
@@ -141,6 +148,11 @@ export const ENGINE_FLAGS = {
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
WantsToBattleText: 0x807a9,
|
||||
BattleText_EnemyWasDefeated: 0x809da,
|
||||
GotMoneyForWinningText: 0x809be,
|
||||
BattleText_EnemySentOut: 0x80af8,
|
||||
BattleText_TheresNoEscapeFromTrainerBattle: 0x80b49,
|
||||
AccuracyLevelMultipliers: 0x34eb2,
|
||||
AlreadyAsleepText: 0x811c1,
|
||||
AlreadyConfusedText: 0x80dcc,
|
||||
@@ -254,6 +266,7 @@ export const BATTLE = {
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
export const NUM_POKEMON = 251;
|
||||
export const NUM_TRAINER_CLASSES = 67;
|
||||
export const NUM_ITEMS = 190;
|
||||
export const NUM_ENGINE_FLAGS = 162;
|
||||
export const NUM_FRUIT_TREES = 30;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { RomReader } from "./reader";
|
||||
import { PokemonData } from "./pokemon";
|
||||
import { NUM_TRAINER_CLASSES, SYMBOLS } from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
import { generatePartyMon, type PartyMon } from "../party/pokemon";
|
||||
|
||||
export interface TrainerAttributes {
|
||||
items: [number, number];
|
||||
baseReward: number;
|
||||
aiMoveFlags: number;
|
||||
aiItemSwitchFlags: number;
|
||||
}
|
||||
export interface TrainerPartyEntry {
|
||||
level: number;
|
||||
species: number;
|
||||
item: number;
|
||||
moves?: PartyMon["moves"];
|
||||
}
|
||||
export interface TrainerDefinition {
|
||||
classId: number;
|
||||
id: number;
|
||||
name: string;
|
||||
className: string;
|
||||
partyType: 0 | 1 | 2 | 3;
|
||||
party: TrainerPartyEntry[];
|
||||
attributes: TrainerAttributes;
|
||||
dvs: number;
|
||||
reward: number;
|
||||
}
|
||||
export interface TrainerHeader {
|
||||
eventFlag: number;
|
||||
classId: number;
|
||||
id: number;
|
||||
seenText: number;
|
||||
beatenText: number;
|
||||
lostScript: number;
|
||||
afterScript: number;
|
||||
}
|
||||
|
||||
/** ReadTrainerParty, GetTrainerDVs and TrainerClassAttributes. Only the
|
||||
* cartridge records are decoded; CAL2's SRAM mystery-gift party is unsupported. */
|
||||
export class TrainerData {
|
||||
private pokemon: PokemonData;
|
||||
constructor(readonly reader: RomReader) {
|
||||
this.pokemon = new PokemonData(reader);
|
||||
}
|
||||
private checkClass(classId: number) {
|
||||
if (
|
||||
!Number.isInteger(classId) ||
|
||||
classId < 1 ||
|
||||
classId > NUM_TRAINER_CLASSES
|
||||
)
|
||||
throw new Error("Unknown trainer class.");
|
||||
}
|
||||
className(classId: number): string {
|
||||
this.checkClass(classId);
|
||||
let p: number = SYMBOLS.trainerClassNames;
|
||||
for (let i = 1; i < classId; i++) p = this.nameEnd(p);
|
||||
return readString(this.reader, p, defaultTextContext(""));
|
||||
}
|
||||
attributes(classId: number): TrainerAttributes {
|
||||
this.checkClass(classId);
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.trainerClassAttributes + (classId - 1) * 7;
|
||||
return {
|
||||
items: [r.byte(p), r.byte(p + 1)],
|
||||
baseReward: r.byte(p + 2),
|
||||
aiMoveFlags: r.word(p + 3),
|
||||
aiItemSwitchFlags: r.word(p + 5),
|
||||
};
|
||||
}
|
||||
dvs(classId: number): number {
|
||||
this.checkClass(classId);
|
||||
const p = SYMBOLS.trainerClassDVs + (classId - 1) * 2;
|
||||
return (this.reader.byte(p) << 8) | this.reader.byte(p + 1);
|
||||
}
|
||||
private nameEnd(offset: number): number {
|
||||
for (let i = 0; i < 32; i++)
|
||||
if (this.reader.byte(offset + i) === 0x50) return offset + i + 1;
|
||||
throw new Error("Invalid trainer name.");
|
||||
}
|
||||
private group(classId: number): [number, number] {
|
||||
this.checkClass(classId);
|
||||
const r = this.reader,
|
||||
bank = r.bank(SYMBOLS.trainerGroups);
|
||||
const start = r.near(SYMBOLS.trainerGroups + (classId - 1) * 2, bank);
|
||||
// The last class contains one trainer and has no following group pointer.
|
||||
const end =
|
||||
classId === NUM_TRAINER_CLASSES
|
||||
? (bank + 1) * 0x4000
|
||||
: r.near(SYMBOLS.trainerGroups + classId * 2, bank);
|
||||
return [start, end];
|
||||
}
|
||||
trainer(classId: number, id: number): TrainerDefinition {
|
||||
const [start, end] = this.group(classId),
|
||||
r = this.reader;
|
||||
if (
|
||||
!Number.isInteger(id) ||
|
||||
id < 1 ||
|
||||
id > 255 ||
|
||||
(classId === NUM_TRAINER_CLASSES && id !== 1)
|
||||
)
|
||||
throw new Error("Unknown trainer ID.");
|
||||
let p = start;
|
||||
for (let skipped = 1; skipped < id; skipped++) {
|
||||
while (p < end && r.byte(p++) !== 0xff) {
|
||||
/* record terminator */
|
||||
}
|
||||
}
|
||||
if (p >= end) throw new Error("Unknown trainer ID.");
|
||||
const name = readString(r, p, defaultTextContext(""));
|
||||
p = this.nameEnd(p);
|
||||
const partyType = r.byte(p++);
|
||||
if (partyType > 3) throw new Error("Unknown trainer party format.");
|
||||
const party: TrainerPartyEntry[] = [];
|
||||
while (p < end && r.byte(p) !== 0xff) {
|
||||
if (party.length === 6)
|
||||
throw new Error("Trainer party exceeds six Pokémon.");
|
||||
const level = r.byte(p++),
|
||||
species = r.byte(p++);
|
||||
if (level < 1 || level > 100 || species < 1 || species > 251)
|
||||
throw new Error("Invalid trainer Pokémon.");
|
||||
const item = partyType & 2 ? r.byte(p++) : 0;
|
||||
const moves =
|
||||
partyType & 1
|
||||
? (Array.from(r.slice(p, 4)) as PartyMon["moves"])
|
||||
: undefined;
|
||||
if (moves) {
|
||||
p += 4;
|
||||
if (moves.some((move) => move > 251))
|
||||
throw new Error("Invalid trainer move.");
|
||||
}
|
||||
party.push({ level, species, item, ...(moves ? { moves } : {}) });
|
||||
}
|
||||
if (p >= end || !party.length)
|
||||
throw new Error("Invalid trainer party terminator.");
|
||||
const attributes = this.attributes(classId);
|
||||
return {
|
||||
classId,
|
||||
id,
|
||||
name,
|
||||
className: this.className(classId),
|
||||
partyType: partyType as TrainerDefinition["partyType"],
|
||||
party,
|
||||
attributes,
|
||||
dvs: this.dvs(classId),
|
||||
// ComputeTrainerReward uses the last party member, not the highest level.
|
||||
// WinTrainerBattle pays four shares when no money is sent to Mom.
|
||||
reward: attributes.baseReward * party[party.length - 1].level * 4,
|
||||
};
|
||||
}
|
||||
createParty(
|
||||
trainer: TrainerDefinition,
|
||||
owner = { otId: 0, ot: "TRAINER" },
|
||||
): PartyMon[] {
|
||||
if (trainer.classId === 12 && trainer.id === 2)
|
||||
throw new Error("CAL2 requires an imported Mystery Gift trainer party.");
|
||||
return trainer.party.map((entry) => {
|
||||
const mon = generatePartyMon(this.pokemon, {
|
||||
species: entry.species,
|
||||
level: entry.level,
|
||||
item: entry.item,
|
||||
dvs: trainer.dvs,
|
||||
caughtData: 0,
|
||||
...owner,
|
||||
});
|
||||
if (entry.moves) {
|
||||
mon.moves = [...entry.moves];
|
||||
// TrainerType2/4 overwrite PP only up to the first empty move. Preserve
|
||||
// the original unused-slot bytes initialized by FillPP.
|
||||
for (let i = 0; i < 4 && mon.moves[i]; i++)
|
||||
mon.pp[i] = this.pokemon.move(mon.moves[i]).pp;
|
||||
}
|
||||
return mon;
|
||||
});
|
||||
}
|
||||
/** Map trainer macro: event, class, ID and four same-bank pointers. */
|
||||
header(offset: number): TrainerHeader {
|
||||
const r = this.reader,
|
||||
bank = r.bank(offset);
|
||||
const pointer = (delta: number) =>
|
||||
r.word(offset + delta) === 0 ? 0 : r.near(offset + delta, bank);
|
||||
const classId = r.byte(offset + 2),
|
||||
id = r.byte(offset + 3);
|
||||
this.checkClass(classId);
|
||||
if (!id) throw new Error("Unknown trainer ID.");
|
||||
return {
|
||||
eventFlag: r.word(offset),
|
||||
classId,
|
||||
id,
|
||||
seenText: pointer(4),
|
||||
beatenText: pointer(6),
|
||||
lostScript: pointer(8),
|
||||
afterScript: pointer(10),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user