// FS2 station extractor. // // Walks the scenery bytecode in each extracted A2.SD* file and pulls // out NAV ($1D), ADF ($05), and COM ($1E) station records. With the // loader correctly identifying the bytecode entry as file offset // 0x9000 (verified via fs2trace), this gives us the per-disk station // database that drives the VOR1/VOR2 + ADF + COM panel readouts. // // Each disk's bytecode ends in a RETURN ($19) or terminator (>= $46 // or bit 7 set). We scan until that point, plus an optional second // pass starting from the next plausible bytecode block (file offsets // 0xA000, 0xC000) to catch records reachable via JMP/sub-invoke that // our static walker can't follow. #include #include #include #include #define BYTECODE_ENTRY 0x9000 #define MAX_STATIONS 4096 typedef struct StationT { uint16_t freq; // 16-bit frequency identifier (encoding varies by record type) int32_t x; // 24-bit signed position (X / east) int32_t y; // 24-bit signed position (Y / north) int32_t z; // optional 24-bit Z (NAV only) char name[16]; char type; // 'A'=ADF, 'N'=NAV, 'C'=COM } StationT; static int adv[256]; static int stationCount; static StationT stations[MAX_STATIONS]; static int bcdNibblesValid(uint8_t b); static int comFreqValid(uint16_t freq); static int adfFreqValid(uint16_t freq); static int navFreqValid(uint16_t freq); static int32_t signed24(uint8_t lo, uint8_t mid, uint8_t hi); static int scanBytecode(const uint8_t *bytes, int length, int entry, int regionIdx); // FS2 packs station frequencies in BCD (chunk5 DecodeBCDFreqString, // chunk3 LookupADFStation). Reject any record whose freq bytes have // non-BCD nibbles or fall outside the legal radio band -- those are // almost always our brute-force scan misreading vertex / polygon // data as a station record. static int bcdNibblesValid(uint8_t b) { return ((b & 0x0F) <= 0x09) && ((b >> 4) <= 0x09); } // ADF: byte 1 = ADFFreqLowPacked (BCD pair, mid+low digits), byte 2 = // ADFFreqHighDigit (single digit -- high nibble must be 0). chunk3 // packs as e.g. $34, $02 -> displayed as "234" kHz, so the 3-digit // range here is 200..999 kHz (NDB beacons; FS2 doesn't represent the // upper 1605..1750 kHz portion since it lacks a 4th digit). static int adfFreqValid(uint16_t freq) { uint8_t lo = (uint8_t)(freq & 0xFF); uint8_t hi = (uint8_t)(freq >> 8); if (!bcdNibblesValid(lo)) { return 0; } if ((hi & 0xF0) != 0x00 || (hi & 0x0F) > 0x09) { return 0; } int khz = (hi & 0x0F) * 100 + ((lo >> 4) * 10) + (lo & 0x0F); return (khz >= 200 && khz <= 999); } // NAV (VOR): byte 1 / byte 2 are the BCD pairs from // DecodeBCDFreqString -- byte1 = lower 2 digits, byte2 = upper 2. // Legal civil VOR band is 108.00..117.95 MHz: hi=$10 requires lo>=$80 // (108.0..109.95) and hi=$11 requires lo<=$79 (110.0..117.95). static int navFreqValid(uint16_t freq) { uint8_t lo = (uint8_t)(freq & 0xFF); uint8_t hi = (uint8_t)(freq >> 8); if (!bcdNibblesValid(lo) || !bcdNibblesValid(hi)) { return 0; } if (hi == 0x10) { return lo >= 0x80; } if (hi == 0x11) { return lo <= 0x79; } return 0; } // COM (airport tower / ATIS): same BCD packing as NAV. Apple II FS2 // uses the civil aviation 118..137 MHz band; high-byte BCD is $11 // ($1180+), $12, or $13 ($1300..$1369). static int comFreqValid(uint16_t freq) { uint8_t lo = (uint8_t)(freq & 0xFF); uint8_t hi = (uint8_t)(freq >> 8); if (!bcdNibblesValid(lo) || !bcdNibblesValid(hi)) { return 0; } if (hi == 0x11) { return lo >= 0x80; } if (hi == 0x12) { return 1; } if (hi == 0x13) { return lo <= 0x69; } return 0; } static int32_t signed24(uint8_t lo, uint8_t mid, uint8_t hi) { uint32_t u = (uint32_t)lo | ((uint32_t)mid << 8) | ((uint32_t)hi << 16); // Sign extend if bit 23 set. if (u & 0x800000) { u |= 0xFF000000; } return (int32_t)u; } // `visited` tracks positions we've already walked from in this file // pass to break cycles introduced by JMP / SUB_INVOKE chains. Sized // to a typical scenery file (143 KB). static uint8_t visited[200000]; static int scanBytecode(const uint8_t *bytes, int length, int entry, int regionIdx) { (void)regionIdx; int pos = entry; int found = 0; int ops = 0; if (pos < 0 || pos >= length || pos >= (int)sizeof(visited)) { return 0; } while (pos < length && pos < (int)sizeof(visited)) { if (visited[pos]) { break; } visited[pos] = 1; uint8_t op = bytes[pos]; if ((op & 0x80) || op >= 0x46) { break; } int a = adv[op]; // Follow $0B (JMP relative). Operand is a signed 16-bit // offset from the current position. Recurse so the // primary walk continues past this branch. if (op == 0x0B && pos + 3 <= length) { int16_t off = (int16_t)(bytes[pos + 1] | (bytes[pos + 2] << 8)); scanBytecode(bytes, length, pos + off, regionIdx); // Don't fall through past JMP -- it's // unconditional, so we're done with this branch. break; } // Follow $18 (sub-invoke). Operand is a signed 16-bit // relative offset to the subroutine; on return ($19) // we continue past the 3-byte sub-invoke record. if (op == 0x18 && pos + 3 <= length) { int16_t off = (int16_t)(bytes[pos + 1] | (bytes[pos + 2] << 8)); scanBytecode(bytes, length, pos + off, regionIdx); // After sub-invoke, fall through to advance 3. } // Follow cull-on-outside jump targets ($20/$21/$22). // Each record's first 2 bytes after the opcode are a // signed 16-bit relative target; the FS2 dispatcher // takes that target when the cull range test fails. // We try BOTH branches statically since we don't know // the camera state at extract time. if ((op == 0x20 || op == 0x21 || op == 0x22) && pos + 3 <= length) { int16_t off = (int16_t)(bytes[pos + 1] | (bytes[pos + 2] << 8)); scanBytecode(bytes, length, pos + off, regionIdx); // Fall through to advance past the inside-path // record (9 / 15 / 21 bytes). } // Pull station data before advancing. if (op == 0x05 && pos + 9 <= length && stationCount < MAX_STATIONS) { uint16_t freq = (uint16_t)(bytes[pos + 1] | (bytes[pos + 2] << 8)); if (adfFreqValid(freq)) { StationT *s = &stations[stationCount++]; s->type = 'A'; s->freq = freq; s->x = signed24(bytes[pos + 3], bytes[pos + 4], bytes[pos + 5]); s->y = signed24(bytes[pos + 6], bytes[pos + 7], bytes[pos + 8]); s->z = 0; s->name[0] = '\0'; found++; } } if (op == 0x1D && pos + 11 <= length && stationCount < MAX_STATIONS) { uint16_t freq = (uint16_t)(bytes[pos + 1] | (bytes[pos + 2] << 8)); if (navFreqValid(freq)) { StationT *s = &stations[stationCount++]; s->type = 'N'; s->freq = freq; s->x = signed24(bytes[pos + 3], bytes[pos + 4], bytes[pos + 5]); s->y = signed24(bytes[pos + 6], bytes[pos + 7], bytes[pos + 8]); s->z = signed24(bytes[pos + 8], bytes[pos + 9], bytes[pos + 10]); s->name[0] = '\0'; found++; } } if (op == 0x1E) { if (pos + 1 >= length) { break; } int recLen = bytes[pos + 1]; if (pos + recLen + 2 <= length && stationCount < MAX_STATIONS) { uint16_t freq = (uint16_t)(bytes[pos + 2] | (bytes[pos + 3] << 8)); if (comFreqValid(freq)) { StationT *s = &stations[stationCount++]; s->type = 'C'; s->freq = freq; s->x = signed24(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6]); s->y = signed24(bytes[pos + 7], bytes[pos + 8], bytes[pos + 9]); s->z = 0; int nameStart = pos + 13; int nameLen = recLen - 11; if (nameLen > 0 && nameLen < (int)sizeof(s->name) - 1) { // Apple II text storage uses bit 7 to mark // "normal" characters (set high bit). Mask // it off before validating; bytes that are // still non-printable after masking get '?'. for (int i = 0; i < nameLen; i++) { uint8_t c = bytes[nameStart + i] & 0x7F; s->name[i] = (c >= 32 && c < 127) ? (char)c : '?'; } s->name[nameLen] = '\0'; } else { s->name[0] = '\0'; } found++; } } a = recLen + 2; } if (a < 0) { // SceneryOpInvalid in chunk5 -- chunk dispatcher // would reset state and bail. Treat as a stream // terminator rather than guessing. break; } if (a == 0) { // Opcode not in our table at all (not an // explicit Invalid either). Advance 1 byte and // try again -- cycle detection bounds the damage // and we may still hit stations downstream. a = 1; } pos += a; ops++; if (ops > 100000) { break; } } return ops; } // Region tag attached to each station so the C-emit output records // where a station was first seen (useful when debugging). static char stationRegion[MAX_STATIONS]; // Dedupe stations by (type, freq, x, y). z is excluded because the // extractor occasionally reads z=0 vs z=junk depending on whether the // trailing bytes of an 11-byte NAV record line up with surrounding // scenery; type/freq/x/y are reliable. static int stationKeyEq(const StationT *a, const StationT *b) { return a->type == b->type && a->freq == b->freq && a->x == b->x && a->y == b->y; } // Stable sort key: type then freq then x then y. Lets the embedded // array be scanned linearly with cache-friendly behaviour. static int stationCmp(const void *pa, const void *pb) { const StationT *a = pa; const StationT *b = pb; if (a->type != b->type) return (a->type < b->type) ? -1 : 1; if (a->freq != b->freq) return (a->freq < b->freq) ? -1 : 1; if (a->x != b->x ) return (a->x < b->x ) ? -1 : 1; if (a->y != b->y ) return (a->y < b->y ) ? -1 : 1; return 0; } int main(int argc, char **argv) { const char *cOutput = NULL; int argi = 1; while (argi < argc && argv[argi][0] == '-') { if (strcmp(argv[argi], "--c-output") == 0 && argi + 1 < argc) { cOutput = argv[++argi]; argi++; continue; } fprintf(stderr, "unknown option %s\n", argv[argi]); return 1; } if (argi >= argc) { fprintf(stderr, "usage: %s [--c-output FILE] file.dsk [file.dsk ...]\n", argv[0]); return 1; } // Advance counts pulled from chunk5 dispatcher (scanning every // `lda #$N; jmp SceneryOpAdvanceAndContinue` / `jmp AddTo8B` // exit). Most opcodes fall through their main path; a few // (vertex-emit family) take different advances depending on // L7EBC's inner branch -- I use the most common 5-byte path. int t[][2] = { { 0x00, 5 }, { 0x01, 5 }, { 0x02, 5 }, // L69C8/L6888/L689F: L7EBC reads 4 stream bytes then `lda #$05; jmp AddTo8B` (chunk5 line 4356) { 0x03, 6 }, { 0x04, 2 }, { 0x05, 9 }, // Call64K_2 + skip + ADF station { 0x06, 5 }, { 0x07, 14 }, // DrawLine + L6BA0 { 0x09, 3 }, { 0x0A, 3 }, { 0x0B, 3 }, // Skip3 + JMP relative { 0x0D, 6 }, { 0x0E, 1 }, // Header + Call64K (no-op fall through) { 0x11, 1 }, { 0x12, 2 }, // Skip1 + SetColor { 0x13, 10 }, { 0x14, 10 }, // L6E17-driven (advance 9 + 1 from caller) { 0x18, 3 }, { 0x19, 1 }, // SubInvoke + Return { 0x1A, 5 }, { 0x1B, 1 }, { 0x1C, 1 }, // L6D66 + ModeWhite + DayOnly { 0x1D, 11 }, // NAV station { 0x20, 9 }, { 0x21, 15 }, { 0x22, 21 }, // 1/2/3-axis cull { 0x23, 7 }, { 0x24, 8 }, { 0x25, 5 }, // L6EF1 + L6B64 + L6D8E { 0x28, 8 }, { 0x29, 1 }, { 0x2B, 5 }, // L6F1D + CopyToD2 + L69E6 (guess) { 0x2F, 1 }, { 0x31, 2 }, { 0x32, 2 }, { 0x33, 2 }, { 0x35, 2 }, // L6987-family: 1 opcode + 1 vertex-index byte { 0x40, 5 }, { 0x41, 5 }, { 0x42, 5 } // L688F/L68A6/L694E: same L7EBC tail as $00-$02 }; for (size_t i = 0; i < sizeof(t) / sizeof(t[0]); i++) { adv[t[i][0]] = t[i][1]; } // Opcodes that dispatch to SceneryOpInvalid in chunk5 -- treat // them as stream terminators so the walker doesn't desync by // guessing a 1-byte advance over what's almost certainly data // misaligned as bytecode. Marked with -1 (sentinel for "halt"). int invalid[] = { 0x08, 0x0C, 0x0F, 0x10, 0x15, 0x16, 0x17, 0x1F, 0x26, 0x27, 0x2A, 0x2C, 0x2D, 0x2E, 0x30, 0x34, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x43, 0x44, 0x45 }; for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { adv[invalid[i]] = -1; } int totalAdf = 0; int totalNav = 0; int totalCom = 0; for (; argi < argc; argi++) { FILE *f = fopen(argv[argi], "rb"); if (f == NULL) { fprintf(stderr, "cannot open %s\n", argv[argi]); continue; } fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET); uint8_t *buf = malloc((size_t)sz); if (buf == NULL || fread(buf, 1, (size_t)sz, f) != (size_t)sz) { fclose(f); free(buf); continue; } fclose(f); int beforeAdf = 0, beforeNav = 0, beforeCom = 0; for (int i = 0; i < stationCount; i++) { if (stations[i].type == 'A') beforeAdf++; if (stations[i].type == 'N') beforeNav++; if (stations[i].type == 'C') beforeCom++; } int countBefore = stationCount; // Reset visited tracker for this file. memset(visited, 0, sizeof(visited)); // Walk from every byte in the FS2 content region. // Real scenery records are nested inside outer opcodes // (e.g. a NAV record at file offset $01452 sits in the // payload of the $13 record at $01449), so 256-byte // sector starts miss most of them. The `visited` guard // makes the walk O(file_size) overall: once a position // is visited any later entry that reaches it bails // immediately. SceneryOpInvalid + freq validation prune // the false-record paths. for (int entry = 0x2000; entry < (int)sz; entry++) { scanBytecode(buf, (int)sz, entry, argi); } int gotAdf = 0, gotNav = 0, gotCom = 0; for (int i = countBefore; i < stationCount; i++) { if (stations[i].type == 'A') gotAdf++; if (stations[i].type == 'N') gotNav++; if (stations[i].type == 'C') gotCom++; // Tag with region label (last path component // after the last '/' or '\\') for the C-emit // comment. const char *base = argv[argi]; for (const char *p = argv[argi]; *p != '\0'; p++) { if (*p == '/' || *p == '\\') { base = p + 1; } } // Pack first letter of region tail (e.g. "A2.SD3" -> '3'). char tag = '?'; for (const char *p = base; *p != '\0'; p++) { if (*p >= '0' && *p <= '9') { tag = *p; break; } } stationRegion[i] = tag; } fprintf(stderr, "%-40s ADF=%d NAV=%d COM=%d\n", argv[argi], gotAdf, gotNav, gotCom); (void)beforeAdf; (void)beforeNav; (void)beforeCom; totalAdf += gotAdf; totalNav += gotNav; totalCom += gotCom; free(buf); } // Dedupe (cross-region): same physical station appears in // every overlapping disk, plus the brute-force walker hits // each in-disk record from multiple aliasing entry points. qsort(stations, (size_t)stationCount, sizeof(stations[0]), stationCmp); int unique = 0; for (int i = 0; i < stationCount; i++) { if (unique > 0 && stationKeyEq(&stations[i], &stations[unique - 1])) { // Prefer the entry that has a name attached // (COM records carry airport names). if (stations[unique - 1].name[0] == '\0' && stations[i].name[0] != '\0') { stations[unique - 1] = stations[i]; stationRegion[unique - 1] = stationRegion[i]; } continue; } stations[unique] = stations[i]; stationRegion[unique] = stationRegion[i]; unique++; } int beforeUnique = stationCount; stationCount = unique; int uniqueAdf = 0, uniqueNav = 0, uniqueCom = 0; for (int i = 0; i < stationCount; i++) { if (stations[i].type == 'A') uniqueAdf++; if (stations[i].type == 'N') uniqueNav++; if (stations[i].type == 'C') uniqueCom++; } fprintf(stderr, "\nraw: %d ADF, %d NAV, %d COM (%d total)\n", totalAdf, totalNav, totalCom, beforeUnique); fprintf(stderr, "unique: %d ADF, %d NAV, %d COM (%d total)\n", uniqueAdf, uniqueNav, uniqueCom, stationCount); if (cOutput != NULL) { FILE *out = fopen(cOutput, "w"); if (out == NULL) { fprintf(stderr, "cannot write %s\n", cOutput); return 1; } fprintf(out, "// Generated by port/tools/extractstations --c-output. Do not edit.\n"); fprintf(out, "// Source: A2.SD* scenery files. %d unique stations.\n\n", stationCount); fprintf(out, "#ifndef SCENERY_STATIONS_DATA_H\n"); fprintf(out, "#define SCENERY_STATIONS_DATA_H\n\n"); fprintf(out, "#include \n\n"); fprintf(out, "typedef struct StationDataT {\n"); fprintf(out, " char type; // 'A'=ADF, 'N'=NAV, 'C'=COM\n"); fprintf(out, " uint16_t freq; // BCD-packed (NAV/COM) or BCD pair + high digit (ADF)\n"); fprintf(out, " int32_t x; // FS2 scenery units, +X = east\n"); fprintf(out, " int32_t y; // +Y = north\n"); fprintf(out, " int32_t z; // NAV only (altitude); 0 for ADF/COM\n"); fprintf(out, " char name[16]; // COM airport name; \"\" otherwise\n"); fprintf(out, " char region; // ASCII digit of source A2.SD* file\n"); fprintf(out, "} StationDataT;\n\n"); fprintf(out, "static const StationDataT kSceneryStations[] = {\n"); for (int i = 0; i < stationCount; i++) { StationT *s = &stations[i]; fprintf(out, " { '%c', 0x%04X, %9d, %9d, %9d, \"", s->type, s->freq, s->x, s->y, s->z); for (int j = 0; j < (int)sizeof(s->name) && s->name[j] != '\0'; j++) { char c = s->name[j]; if (c == '"' || c == '\\') { fputc('\\', out); } fputc(c, out); } fprintf(out, "\", '%c' },\n", stationRegion[i]); } fprintf(out, "};\n\n"); fprintf(out, "#define SCENERY_STATIONS_COUNT ((int)(sizeof(kSceneryStations) / sizeof(kSceneryStations[0])))\n\n"); fprintf(out, "#endif\n"); fclose(out); fprintf(stderr, "wrote %s (%d entries)\n", cOutput, stationCount); return 0; } printf("\ntotal raw: %d ADF, %d NAV, %d COM\n", totalAdf, totalNav, totalCom); printf("unique: %d ADF, %d NAV, %d COM\n", uniqueAdf, uniqueNav, uniqueCom); for (int i = 0; i < stationCount; i++) { StationT *s = &stations[i]; printf(" %c freq=$%04X x=%d y=%d z=%d %s\n", s->type, s->freq, s->x, s->y, s->z, s->name); } return 0; }