|
FhSim
3.1.0
Marine systems simulation
|
| ID | 0021 |
| Class | BUG |
| Severity | 3 |
| Status | ready |
| Models | Body/Mass, Math/Integrator, Math/LinearSystem, Math/LowPass |
| Found | 2026-09-22 |
ISimObjectCreator::AddState returns a model-global state offset, but the Jacobian callbacks are handed a local slice. Mixing the two is silently correct for the first stateful SimObject in a model and wrong for every other one.
The framework side is unambiguous:
AddState returns globalOffset, accumulated across all SimObjects in the model: fhsim/src/simobject/SimObjectOrganizer.cpp:470-471 and :477. One organizer serves the whole model (fhsim/src/engine/model/ModelAssemblyService.cpp:75), NewSimObject does not reset the counter (SimObjectOrganizer.cpp:31-39), and ModelAssemblyService.cpp:78 uses GetNumStates() as the model's total state count.OdeFcn receives the global vector, un-offset: fhsim/src/engine/model/ModelStructure.cpp:89 — simObject->OdeFcn(T, X, XDot);OdeJacobian receives a local slice and a local dimension: fhsim/src/engine/model/JacobianAssembler.cpp:468 — simObject->OdeJacobian(time, state + offset, localJ.data(), simObjectStateCount);OutputPortJacobian / InputPortJacobian likewise: JacobianAssembler.cpp:501-507 — state + link.sourceOffset / state + link.consumerOffset, sized link.sourceSize / link.consumerSize.So using an AddState return value inside OdeFcn is correct, and using it inside a Jacobian callback is not. The local index is AddState return value - (this object's smallest AddState return value).
Affected in this library:
src/body/CMass.cpp:114-141 — both OutputPortJacobian (dPort_dX[i*nStates + m_IStatePos + i]) and InputPortJacobian (dF_dInput[(m_IStateVel + i) * portSize + i]).src/math/CIntegrator.cpp:96-116 — m_StateIndex.src/math/CLinearSystem.cpp:78 onwards and src/filters/CLowPass.cpp:81 onwards — the same m_StateIndex / m_current_signal pattern.The same fault exists in fhsim_fishery (TrawlDoorBase, TrawlVessel, CenterWeight, Auv) and is filed there separately.
Two failure modes, and the second is the serious one.
CMass::InputPortJacobian writes at (m_IStateVel + i) * portSize + i into an nStates x portSize = 6x3 = 18 element workspace. A second Body/Mass in a model, with its Pos at global offset 6, gives m_IStateVel = 9 and an index of 29 — eleven elements past the end of m_PortJacWorkspace_dF_dInput. The overflow scales with how late the object appears, so a large model writes far past the buffer.Every one of these classes is exercised today only in fixtures where it is the sole stateful object, which is exactly why this has not been caught. Body/Mass is used in the published MassSpring FMU, where it happens to be first and therefore safe.
Cache the object's own base offset at construction, immediately after the first AddState, and subtract it in every Jacobian callback while leaving OdeFcn untouched:
Worth pairing with a framework-side guard, filed separately against fhsim: the assembler knows simObjectStateCount, so it could assert that a callback wrote nothing outside the block instead of trusting it, which would have turned this into a loud failure years earlier. Documenting the local-versus-global split on AddState itself would also help; it currently says nothing, and the name globalOffset is only visible inside the framework.
Build a model with two Body/Mass objects, the one under test second, connect a force port to each, and run with an implicit method so the port Jacobians are assembled. Under ASan the current code reports a heap-buffer-overflow in InputPortJacobian; without ASan the Jacobian block is silently wrong. A cheaper unit-level version: call InputPortJacobian directly with a deliberately non-zero base offset and assert every write lands inside nStates * portSize.
Add the two-object ordering to the existing Jacobian fixtures generally, since a single-object fixture cannot detect this class of bug for any SimObject.
Fixing it changes the assembled Jacobian for any model where an affected object is not first, which can move a recorded regression baseline. That is a genuine behaviour change and should be reviewed as one — but the current behaviour includes out-of-bounds writes, so leaving it is not an option.