|
RTE RBC 2oo2 Generic Application 0.1.0 build 308eda7
IL/CTC route and train logic for the RBC_GP reference RBC
|
Design history of the Generic Application and its boundary to RBC_GP. The rules that apply today are in ../CLAUDE.md; this file records how the design got there and why, so the decisions are not re-litigated. Product names in this file: RBC_GP (was RBC_GP), RBC_GA, RBC_SA.
Unlike RBC_SA (zero coupling either direction - ordinary static linking), GP and GA have a real circular relationship: GP calls into GA, but GA also needs to call BACK into GP - route/train getters/setters GP owns, plus registering GA's own callbacks into GP's notification lists at init time.
Superseded (later session): the function-pointer-struct handoff.** GP used to populate one real instance of ab_gp_ga_interface_t (ga_interface.h) and hand GA a pointer to it at init; GA stored it (ab_ga_iface_set()/_get(), ab_ga_iface.c/.h, now DELETED) and called every GP-owned function through ab_ga_iface_get()->the_field(...). This existed only because RBC_GP used to be a plain executable (rte_example) - an executable can't be the target of a real bidirectional link the way two shared libraries can.
Current design: symmetric dummy-then-real shared-library linking, genuinely bare C calls both directions.** Per direct instruction, GP itself became a shared library too (librte_gp, RBC_GP's own CMakeLists.txt + launcher_main.c - see that project's own CLAUDE.md). This makes it a real Mach-O/ELF shared-library-to-shared-library mutual dependency, solvable with the same mechanism ABI-stable dylib ecosystems use everywhere: each side is built against a real, if empty, DUMMY of the other, and the REAL library replaces the dummy on disk before the process ever runs.
AB_GA_CHECKPOINT_MARK() also dropped its proxy: GA links rte::appmanager directly now and the macro is a thin alias for the framework's own RTE_CHECKPOINT_MARK() - no interface indirection needed for this one either (it was never GP-specific data to begin with).
Verified end to end** (same session): clean cmake --build+--install for both projects, nm -u on the real installed librte_rbc2oo2_ga confirms every referenced ab_gp_* symbol is genuinely exported by the real installed librte_gp (comm -23 diff, zero missing), smoke.sh core 8/8 checks pass, ctest 2/2 pass, and a real local-env Robot run (02_il_grants_a_movement_authority) PASSED with CTC_CONNECTED/CTC_MA_GRANTED/ CTC_TRAIN_INFO output byte-shape unchanged from before this restructuring.
Safety-process note, unchanged from the plan that authorized this**: this trades a build-time-verified property (the old "zero unresolved symbols at all" check) for a runtime-dependent one - the real librte_gp must actually be the file present at process load time, not the dummy. If a deploy step is ever misconfigured such that only the dummy ships, every GA->GP call silently becomes a no-op instead of failing to link - flagged, not blocking.
Revised again, same later session, on direct request: the dummy moved to GP's side, and GP's own bare-call surface into GA shrank to one function.** Two changes on top of the design above:
Net effect: GP's own dummy_ga.c stubs exactly ONE function now and needs no #include of any of this project's own headers at all (the single declaration it stubs lives entirely in GP's ga_interface.h). Re-verified end to end after this second revision: nm -u/nm -gU both directions still show zero missing symbols, smoke.sh core 8/8 and ctest 2/2 still pass, and a fresh local-env Robot MA-grant run passed with identical CTC_CONNECTED/CTC_MA_GRANTED/ CTC_TRAIN_INFO content.
ga_interface.h had grown to 43 function-pointer fields - most strikingly, six separate notification-registration entries (route_added/route_status/route_connected/ route_disconnected/train_registered/train_status), each with its own callback typedef, subscriber list, and registration call site scattered across il.c/ctc.c/ ma_logic_adaptation.c. Confirmed live (grepped every call site) that 4 of the 6 had ZERO real GA subscribers. Per direct instruction, collapsed to ONE subscription per domain:
ab_ga_ctc_build_outputs()'s own CTC_TRAIN_INFO block is now reactive, not a per-cycle poll+diff scan - the direct payoff of this whole redesign. It used to call ~6 GP getters per train, every cycle, and compare against a "last sent" snapshot GP's own session had to store (ctc_train_info_sent/last_ctc_train_info_*, all now DELETED). Now ab_ga_ctc_on_train_context_changed() (the dispatch fan-out target) maintains a small GA-local g_train_info_pending[]/g_last_train_ctx[] pair, and build_outputs() just drains it. A real regression was found and fixed during verification: GP fires AB_TRAIN_POSITION on EVERY M136, including a stationary train's routine periodic report - without a value comparison SOMEWHERE, this meant CTC_TRAIN_INFO got re-sent every single cycle regardless of real change, exactly what the redesign was meant to eliminate. Fixed by moving the diff into ab_ga_ctc_on_train_context_changed() itself (comparing the incoming context's d_lrbg/nid_lrbg/v_train/m_mode/granted_length/ma_seq/has_ma against the last-known snapshot before marking pending) - the comparison still exists, just owned by GA (where it conceptually belongs) with a far smaller surface than the six interface functions it replaced. Verified end to end, both directions: a real MA grant (route connect) and a real MA release (route disconnect, the ROUTE_RELEASE_ZEROED_MA path) each produced exactly one fresh CTC_TRAIN_INFO reflecting the change, with no redundant resends on unchanged cycles in between.
Follow-up, same later session: individual GP getters replaced with two context-snapshot pulls.** ga_interface.h still had ~14 individual ab_gp_train_session_get_*()/ ab_gp_db_route_get_*() reads scattered across il.c/ctc.c even after the notify consolidation above - on direct request ("it will be enough if GA can getTrainContext/ getRouteContext; setters stay separate since they require validation"), these collapsed to two pull-equivalents of the push snapshots already described: ab_gp_get_train_context()/ ab_gp_db_get_route_context() (same ab_gp_train_context_t/ab_gp_route_context_t structs, same builder functions GP's own notify path already used, just also reachable on demand). Setters were deliberately left untouched (mutation still needs GP's own validation). GP-internal dead code was found and deleted along the way (zero callers even before this pass: ab_gp_train_get_has_ma(), ab_gp_train_session_get_ctc_connected_sent()); two GP-internal getters GA no longer reaches directly stayed in GP's own headers because GP itself still calls them (ab_gp_train_get_last_position(), ab_gp_db_train_get_routes()) - only their facade declarations moved out of what GA can call by bare name, not their bodies. One genuine exception stayed a standalone getter rather than folding into the context pull: ab_gp_train_get_last_position() is also called from GA's own route-connect validator (ma_logic_adaptation.c's validate_route_assignment()), whose GP-registered callback signature only ever receives db as context - no sessions[] is available there to build a full train context snapshot.
Found via a direct question about a Robot test helper** (ILSimSupportFunctions.py's send_il_message(nid_engine)): the REAL ROUTE_ADD/ROUTE_RELEASE handlers (AB/il/ab_ga_il.c's handle_route_add()/handle_route_release()) read env->train_id straight off the wire and granted/shrank Movement Authority for THAT train directly - no position check, no validator, bypassing ab_gp_db_route_connect_train() entirely. Meanwhile a completely separate, ALREADY-EXISTING mechanism - AB/GP/ma_logic/ab_gp_logic.c's ab_gp_logic_connect_routes_and_trains(), called every cycle from execute.c - independently matched each connected train's own last reported position against every tracked route's balises (through the registered validator, validate_route_assignment(), ma_logic_adaptation.c) and, on a match, called the REAL connect function (ab_gp_db_route_connect_train()) - but that path only ever produced a cosmetic TrainPositionInRoute indication (ab_ga_il_on_route_context_changed()'s old body), never an actual MA change. Two parallel, disagreeing mechanisms for the same real question ("which train does this route belong to") - on direct request, IL must not be the one deciding this at all; GP/GA's own position-based gate is now the ONLY path:
A genuinely dead function was found and removed along the way: train_id_in_range() had no callers left once handle_route_add() stopped validating a wire-supplied train_id.
Verified end to end, both directions, via real local-env Robot runs** (not just a clean build): 02_il_grants_a_movement_authority PASSED, with the live log showing the new flow in order - "route 1 tracked ... MA grant awaits GP's own position-based connect" immediately followed by GP's own "ROUTE_CONNECT ... connected route to train" in the SAME cycle (pre_execute adds the route, execute's own connect-gate sweep runs right after, same AppManager cycle - no functional delay), and CTC_MA_GRANTED/CTC_TRAIN_INFO output byte-identical in content to every prior verified run. 07_il_receives_train_position_in_route (exercises release) PASSED too, with "IL notified: route 1 disconnected from train 1" confirming the disconnect path correctly resolves the real train via ctx->train_id, not a wire-supplied one.
Follow-up done (later session)**: ILSimSupportFunctions.py's send_il_message/ send_il_route_with_raw_fields/release_il_route no longer take ${nid_engine} at all - IL genuinely does not know, or need to know, which train a route command concerns now, matching the C-side fix above. A fixed _NO_TRAIN = 0 placeholder is used internally for the sendMessage JSON command's still-required nidEngine schema field. Swept across all 13 .robot files calling these three keywords. wait_for_il_message/verify_il_packet (the RECEIVE side - GP/GA's own real per-train TrainPositionInRoute fact) are unchanged, still take a real ${nid_engine}. Verified: robot --dryrun 45/45 across the whole suite (this sweep also surfaced and fixed an unrelated pre-existing bug in train_traverses_entire_track_layout.robot - it called Set IL Route with route_len=0, which falls back to SiteDataSupportFunctions.get_route_len_by_signals(), but that file never imported SiteDataSupportFunctions.py as a Robot library - fixed by adding the import).
Added so RBC_Test_Env's web_runner (see that project's own root-CLAUDE.md entry) can build its main train-position/MA panel EXCLUSIVELY from CTC - the one real observation point - instead of a mix of Train-sim wire traffic and IL events, matching the same principle the IO/GENERAL log-classification rule already established. Kind 24 (rbc_wire_types.h, AB copy only - C just relays, no decode needed there), deliberately reusing every existing generic rbc_envelope_t field rather than widening the struct (same "reuse, don't grow" convention RBC_MSG_CTC_ALARM already set for msg_type/ma_seq/ t_train) - see that kind's own doc comment for the exact field-reuse mapping (d_lrbg= position, msg_type=MA status, route_len=MA target signal, t_train=front signal, cycle=distance to it, ma_seq=real MA sequence, ma_length=signed MA offset as a raw two's-complement uint32_t bit pattern).
Front/target signal**: route-boundary only (not a full segment/pos_m topology walk, a deliberate scope decision) - ab_ga_ctc.c's file-static compute_train_info_signals() walks the train's held-route chain (ab_gp_db_train_get_routes(), ga_interface.h) against ab_siteGA.h's own static prev_route_id/next_route_id links (NOT the held-route array's own storage order, which is not a documented ordering guarantee) to find the earliest not-yet-reached route boundary (front signal) and the last held route's own end_signal (MA target signal).
Emit cadence**: on-change only, not every cycle (unlike CTC_CONNECTED's own re-send-until- superseded pattern) - new sticky snapshot fields on ab_gp_train_session_t (RBC_GP/.../ab_gp_train.h: ctc_train_info_sent, last_ctc_train_info_*), local scratch only, NOT wire/cross-compare relevant (same posture as ma_pending_send). A real bug found live in a later session, via a user's own inspection of a merged test log: the change comparison covered front/target signal, distance, and MA-granted status, but NOT d_lrbg itself - so a train holding no route (front/target signal frozen at 0) silently stopped getting position updates the moment its MA status last changed, even while still physically moving. Fixed by adding last_ctc_train_info_d_lrbg to the comparison (and to the grouped ab_gp_train_session_get_last_ctc_train_info()/_set_last_ctc_train_info() getter/setter pair, ga_interface.h) - re-verified end to end afterward.
ab_ga_ctc_build_outputs() itself gained a new ab_gp_db_t *db parameter (needed for the route-chain lookup above; every other output it builds needs no db access) - RBC_GP/.../post_execute.c's call site updated to pass &ctx->db. AB_GA_CTC_MAX_PENDING_OUTPUTS widened from MAX_TRAINS * 2 to MAX_TRAINS * 3 (a third possible per-train output per cycle now). RBC_Test_Sim_CTC's message_catalog.json/ctc_sim.py decode it into semantically-named train_status fields (see that project's own history) - RBC_Test_Sim_Core's rbc_wire.py needed no change, the reused fields were already decodable generic names.
nid_lrbg/v_train/m_mode (later session, on direct request)** - unlike the fields above, these are NOT reused generic slots; they ride at the SAME fixed, kind-independent canonical offsets M136 already defines for them (40/64/72 in the shared 96-byte envelope - RBC_Test_Sim_Core/rbc_wire.py's own module doc is explicit that every field lives at one fixed offset regardless of kind, precisely so different kinds' own extension fields never alias onto the same wire bytes). ctc_wire_encode() seeks to each offset explicitly (rte_buffer_set_length()) rather than writing them sequentially after ma_length - a real, found-live bug in an earlier attempt at this same addition did exactly that (inserted nid_lrbg into the middle of the base 24-byte layout with no l_message change, silently shifting msg_type/route_len/ma_seq/ma_length by 4 bytes each and pushing ma_length past the declared length entirely), which would have broken every existing CTC_* consumer, not just this new kind. l_message for RBC_MSG_CTC_TRAIN_INFO specifically is 76 (through m_mode's own offset), gated on env->kind == RBC_MSG_CTC_TRAIN_INFO - every other CTC_* kind's layout/length is completely untouched. v_train/m_mode needed new session fields (ab_gp_train_session_t.v_train/.m_mode, persisted from every M136 in ab_gp_train_apply_envelope()'s own RBC_MSG_M136 case - never stored anywhere before this); nid_lrbg's CURRENT value already existed (ab_gp_train_get_last_position(), db-level, not session) - a second bug in the same in-progress edit called this with sessions as its first argument instead of db (a type mismatch), fixed alongside the offset bug. All three are now part of the on-change comparison too - this directly fixed a real user-reported symptom ("CTC info is not generated when train position changed via M136": v_train/nid_lrbg weren't tracked anywhere before, so pure-M136-driven changes with an unchanging route/MA couldn't be detected). See ISSUES.dox's own entry for the full verification.