Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1824edb
0.4.0 릴리스 뒤 main 을 dev 에 병합한다
cmir79 Sep 10, 2026
56417ae
dev 의 판 번호를 0.4.1-dev 로 — 0.4.0 은 나갔다
cmir79 Sep 10, 2026
060d77a
MG-A320K-35 절에 펌웨어와 포트 구성을 적는다
cmir79 Sep 10, 2026
5ebc720
IpConfig: 목록을 하나로 합쳐 선택이 씹히고 둘로 보이던 것을 고친다
cmir79 Sep 10, 2026
df4446a
IpConfig: 어댑터가 바뀌어 목록을 다시 묶어도 카메라 선택이 남게 한다
cmir79 Sep 10, 2026
092174f
레지스터 쓰기가 보낸 뒤 실패해도 캐시가 옛 값을 붙들지 않게 한다
cmir79 Sep 26, 2026
ec9da88
실수 노드(SwissKnife·Converter)의 수식을 실수로 나눈다
cmir79 Sep 26, 2026
55f0027
FormulaScope 문서 주석의 cref 모호·param 태그 경고를 없앤다
cmir79 Sep 26, 2026
49a523e
프레임 완성을 패킷 수가 아니라 리더가 알린 바이트까지로 판정한다
cmir79 Sep 26, 2026
3f67405
스트림이 장치보다 오래 산다는 것을 문서에 적고 테스트로 못 박는다
cmir79 Sep 26, 2026
b3b1a45
끊긴 블록의 마지막 패킷이 짧으면 그 뒤 틈도 0 으로 비운다
cmir79 Sep 26, 2026
41e1b82
수식 두 규칙·쓰기 실패 무효화·완성 판정을 설계 문서에 맞춘다
cmir79 Sep 26, 2026
d3ab7a0
홀수 픽셀 수 GVSP Packed 의 1 바이트 과대 계산을 실측해 적고 경고 문구로 드러낸다
cmir79 Sep 26, 2026
52fc84f
실수 규칙의 나머지를 정수로 자르고, 거듭제곱이 NaN·0 나눗셈을 값으로 흘리지 않게 한다
cmir79 Sep 26, 2026
c35e105
0.4.1 로 판 번호를 확정한다
cmir79 Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
내지 않는다 — 다음 판에 같이 나간다). 올린 번호는
다시 쓰지 않는다 — NuGet 은 지울 수도 덮어쓸 수도 없다. publish.yml 이 태그와 이 값이 같은지, 태그 커밋이
main 에 있는지 검사하고, 태그가 아닌 ref 에서는 올리지 않는다. -->
<Version>0.4.0</Version>
<Version>0.4.1</Version>

<Authors>kintaein</Authors>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,13 @@ await stream.StartAsync();
await dev.SetTlParamsLockedAsync(true);
await nodes.GetCommand("AcquisitionStart").ExecuteAsync();

// The stream does not stop when the device is lost or disposed, and a wait for a frame from a silent
// device never ends on its own: pass a token, or StopAsync the stream from the ControlLost handler.
using var cts = new CancellationTokenSource();
for (var i = 0; i < 10; i++)
{
using var frame = await stream.ReceiveAsync(); // complete frames only
cts.CancelAfter(TimeSpan.FromSeconds(2)); // per-frame deadline
using var frame = await stream.ReceiveAsync(cts.Token); // complete frames only
Console.WriteLine($"{frame.FrameId}: {frame.Width}x{frame.Height} "
+ $"{PixelFormatInfo.Name(frame.PixelFormatCode)} stride={frame.Stride}");
}
Expand Down
33 changes: 29 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ public sealed class GevStream : IAsyncDisposable
// then **drain the queue and Dispose every frame still in it** — skipping that
// leaves those pool buffers held forever — and complete pending receives
// with GevStreamClosedException
public ValueTask<GevFrame> ReceiveAsync(CancellationToken ct = default);
public ValueTask<GevFrame> ReceiveAsync(CancellationToken ct = default); // waits until a frame, the token, or StopAsync/DisposeAsync —
// NOT until the device goes away (see "Stream lifetime" below)
public bool TryReceive(out GevFrame? frame);
public ValueTask DisposeAsync();
}
Expand Down Expand Up @@ -513,9 +514,22 @@ Formula layer (`GenApi/Formula`): `Formula.Parse(string) → Formula` (immutable
`Formula.Evaluate(Func<string, GenApiValue> resolve) → GenApiValue` where `GenApiValue` is an
int64/double union. Grammar: `+ - * / % ** & | ^ ~ << >> && || ! < > <= >= = == <> != ?:`, parentheses,
decimal / hex (`0x`) / float literals, `PI`/`E`, functions `SIN COS TAN ASIN ACOS ATAN ABS EXP LN LG SQRT
TRUNC FLOOR CEIL ROUND SGN NEG`. Precedence follows C. Integer ⊕ integer stays integer (`/` truncates,
`**` integer when exponent ≥ 0); any double promotes. Division by zero and invalid operations throw
`GenApiException` — never return 0 silently. Parse depth is bounded; variable names are identifiers
TRUNC FLOOR CEIL ROUND SGN NEG`. Precedence follows C. Two evaluation rules, chosen by the node that owns
the formula (`FormulaMode`, a required argument of `FormulaScope`):
- *Integer* — `IntSwissKnife`, `IntConverter`, inline address formulas, and the public `Formula.Evaluate`:
integer ⊕ integer stays integer (`/` truncates, `**` integer when exponent ≥ 0, overflow throws); any
double promotes; bitwise operators reject doubles.
- *Real* — `SwissKnife` and `Converter` (the formula, its `Expression`s and the Converter limit mapping):
the value is a float, so `/` gives a real result even between integers (`1000000 / N`,
`10 ** ((TO / 10) / 20)` with an integer register `TO`). `+ - *` and `**` with a non-negative exponent stay
exact integers between integers but continue in double instead of throwing on overflow. Bitwise operators,
shifts and `%` truncate double operands toward zero, so `(N / 2) & 1` and `(N / 2) % 2` give what integer
division followed by that operator gave (either sign, while the operands fit 2^53); NaN/out-of-range throws.

Division by zero and invalid operations throw `GenApiException` — never return 0 silently. That includes
`**`: a zero base with a negative exponent throws in both rules, and an undefined real result (a negative base
with a fractional exponent) throws instead of returning NaN. A magnitude beyond `double` stays ±Infinity like
any other double arithmetic; the Converter limit mapping reads such an endpoint as an open end. Parse depth is bounded; variable names are identifiers
(letters, digits, `_`, `.`) and are resolved by the caller from `<pVariable Name="X">Node</pVariable>`.

Runtime layer (`GenApi/Runtime`): concrete node classes implementing the public interfaces over the
Expand Down Expand Up @@ -575,6 +589,12 @@ GenApi runtime — implementation notes where the behaviour is more specific tha
Registers that share bytes without a graph edge (StructReg entries, alias registers) are found by address
overlap and dropped. `INode.Invalidate()` uses the same closure but includes the node itself and its whole
value chain.
- A write that **throws** is treated as "the device may hold the new value": a GVCP command leaves before its
acknowledge is awaited, so a lost reply, a timeout after PENDING_ACK or a cancelled wait all arrive here with
the device already changed. The register drops its own cache and every overlapping one, and the node drops
the same closure as `INode.Invalidate()`, then the exception propagates. The exception type is not
inspected — if the device refused or the command never left, the cost is one extra read. The write shadow is
left as it was: there is no way to record "unknown", and clearing it would zero sibling fields for certain.
- Write-only registers cannot be read for a read-modify-write, so the node map keeps a write shadow — the
bytes it last wrote at each address — and uses it as the base: a field written through one
`MaskedIntReg`/`StructEntry` survives the next write of a sibling field. Bytes never written read as 0.
Expand Down Expand Up @@ -632,6 +652,11 @@ nowhere — every public type of `GevSharp` belongs to exactly one line here.
(pinned by `GevDeviceTests`). Two consequences the caller should know: `DisposeAsync` does not wait for
the handler, so it can still run after `Close()` returns, and an exception from the handler is swallowed
and logged rather than propagated.
- **Stream lifetime is independent of the device.** `GevDevice` does not keep the streams it opened, so
neither `ControlLost` nor `DisposeAsync` on the device stops them: the receiver thread keeps listening,
`IsStarted` stays true, and a `ReceiveAsync` waiting for a frame from a device that went silent never
returns on its own. Pass a cancellation token, or call `StopAsync` on the stream from the `ControlLost`
handler (and before disposing the device). Pinned by `DeviceLifecycleTests.Stream_OutlivesItsDevice_*`.

## Testing strategy

Expand Down
11 changes: 10 additions & 1 deletion docs/design-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Derived from a code-level survey (2026-09-02) of six public .NET/C implementations. Each item below is a
place where an existing implementation broke in practice; GevSharp treats them as acceptance criteria.
R27–R29 were added on 2026-09-26 from defects found in this library itself; they belong here for the same
reason — each is a value that looked normal while being wrong.

The status table at the end records where each requirement lives and what would fail if it were removed.
It is verified by deleting the behaviour and running the suite, not by reading names — "implemented" and
Expand Down Expand Up @@ -35,10 +37,14 @@ It is verified by deleting the behaviour and running the suite, not by reading n
| R24 | Repository-wide CRLF via `.gitattributes`; no mixed line endings. | Mixed endings turned one-line edits into whole-file diffs. |
| R25 | Cached camera XML is opt-in and written to a caller-chosen directory with a stable name. | XML copies piled up next to the executable on every connect. |
| R26 | Register access from GenApi is async end-to-end; no sync-over-async. | Thread-pool starvation under load. |
| R27 | Float formula nodes (SwissKnife, Converter) divide as reals even when both operands come from integer registers. | Found in this library (2026-09-26): a frame-rate SwissKnife read 21 Hz exactly, a dB gain read 1.0 for every raw value below 200 and its truncated pMax rejected valid writes. |
| R28 | A register write that throws after the command may have left (lost reply, timeout, cancelled wait) drops the caches it would have updated or invalidated; the next read asks the device. | Found in this library (2026-09-26): after a lost reply to AcquisitionStop the cached "acquiring" value kept AcquisitionMode locked without asking the device. |
| R29 | A frame is complete only when every byte the leader announced was received — the trailer's packet count alone is not enough. | Found in this library (2026-09-26): a block cut short by an early trailer was delivered `IsComplete = true` with the previous frame's pixels in the unreceived tail. |

## Status

Verified 2026-09-03 against the tree at that time. `met` = implemented **and** a named test fails when the
Verified 2026-09-03 against the tree at that time (R27–R29: 2026-09-26, each named test run against the tree
before its fix and seen failing). `met` = implemented **and** a named test fails when the
behaviour is deleted. `met-untested` = implemented, but deleting it leaves the suite green — the requirement
holds today and nothing would notice a regression. `partial` = some cases guarded, others not.

Expand Down Expand Up @@ -70,6 +76,9 @@ holds today and nothing would notice a regression. `partial` = some cases guarde
| R24 | met | `.gitattributes:2` | `RepositoryPolicyTests.EveryTrackedTextFileIsStoredWithLfAndCheckedOutAsCrlf` asserts every tracked text file is `i/lf` (binaries `i/-text` are exempt) — the index judgment, not a byte count, because a stray CR can fold in the clean filter and still reach the commit; mutation-checked: staging a file with a doubled CR shows `i/mixed` and fails it |
| R25 | met | `GevDeviceOpt.XmlCacheDir` (null = off), `Xml/GevXmlLoader.cs:119-137,169-177,420-442` | `GevXmlLoaderTests.NoCacheDirMeansNothingIsWritten`, `CacheFileNameIsSanitizedAndStable`, `CacheMissWritesFileAndHitSkipsDeviceXmlRead` |
| R26 | met | `IGevPort` has no sync surface; `RegisterCore.cs:147,180` await the port | `RepositoryPolicyTests.TheLibraryNeverBlocksOnAnAsyncResult` scans every library source for `.Result`, `.Wait()`, `GetAwaiter().GetResult()`, `Task.WaitAll/WaitAny` and `RunSynchronously()` (`Task.Run` is allowed — moving a blocking join or socket wait off the caller is not the same thing) — mutation-checked: planting one `.Result` fails it |
| R27 | met (2026-09-26) | `GenApi/Formula/FormulaOps.cs` (`FormulaMode.Real` — `Divide`, `Pow`, overflow fallback, `BitOperand`), `Runtime/FormulaScope.cs` (mode is a required argument), `FloatNodes.cs` (SwissKnife/Converter pass `Real`) | `FloatNodeTests.SwissKnife_DividesIntegerRegistersAsReals` (with an IntSwissKnife control that still truncates), `SwissKnife_NestedExpressionDividesAsReal`, `SwissKnife_ShiftStaysIntegerWhileDivisionIsReal`, `Converter_DecibelRegisterReadsAsReal`, `Converter_WriteWithinRealLimitsIsAcceptedAndReadsBack`, `FormulaTests.RealMode*` — all five node tests failed on the tree before the fix |
| R28 | met (2026-09-26) | `Runtime/RegisterCore.cs` (`WriteAsync` catch), `GenApiNodeMap.Runtime.cs` (`OnRegisterWriteFailed`, `OnWriteFailed`), the eight node write paths | `CacheInvalidationTests.WriteFailedAfterSend_NextReadAsksTheDevice`, `_DropsDependentCaches`, `_LockPredicateAsksTheDeviceAgain` (failed before the fix), `_WriteOnlyShadowKeepsSiblingBits`; `GevStreamTests.ScpWriteFailingAfterSendIsStillReset` for the stream-channel port |
| R29 | met (2026-09-26) | `GevStream.Receiver.cs` (`IsComplete`, `IsCutShort`, `ApplyTrailerHeight`, `ZeroHoles`) | `GevStreamTests.BlockCutShortByAnEarlyTrailerIsIncompleteNotStale` (dirties the pool buffer first so a stale tail is visible), `BlockCutInsideAPacketZeroesTheGapAfterTheShortLastPayload`, `BlockCutShortIsDroppedWhenIncompleteFramesAreNotDelivered`, `LeaderRecoveredAfterAShorterTrailerStillShrinksTheFrame`, `VariableHeightOfABitPackedFormatKeepsItsPayloadSize` — all failed before the fix |

The four *policy* requirements — "no commercial dependency", "no vendor XML", "CRLF", "no sync-over-async" —
are properties of the repository rather than runtime behaviours, so they are guarded by
Expand Down
26 changes: 20 additions & 6 deletions docs/evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ the "no byte stride" signal, and the sidecar records it. Widening to 2592 puts i
branch: `PayloadSize` 248,832, `Stride` 3888, 0 incomplete. Both branches were exercised against hardware,
not only against the simulator.

**Odd pixel total (2026-09-26).** Every geometry above has an even `width x height`, so `w*h*12/8` is a whole
number. At 2591 x 1943 Mono12Packed (5,034,313 pixels, odd) the device's `PayloadSize` is **7,551,470** =
`ceil(p x 1.5)` — the lone last pixel takes two bytes — while the receiver's continuous-run rule still rounds to
a whole group and expects **7,551,471**, one byte more. Frames nevertheless complete: 10 frames streamed, 11
completed, 0 incomplete, because the device's last packet carries at least that many bytes and the copy is
clamped at the expected size. This matters since frame completion also requires every byte the leader implies
(R29): a device that sent exactly its own `PayloadSize` with no padding in the last packet would have every such
frame closed as incomplete, with the one-time warning naming both sizes. Not observed on this camera; the rule
is left as is until a device shows it, and the warning is what would surface it.

`PixelFormatInfo.FrameBytes` is the single definition of that and `GvspImageLeader.ImageBytes` routes
through it, so the receiver sizes a frame the way the device does. Where a line is not a whole number of
bytes and there is no line padding there is no stride at all, and saying so is part of the fix:
Expand Down Expand Up @@ -514,12 +524,16 @@ which stayed at zero here.
This entry is reported from a deployment rather than measured on the bench, so it has no table. An
assembly-line inspection station runs two of these cameras (2062x1544, 30 ms exposure, 14 fps)
through the CvInspect adapter, with no vendor SDK installed on the machine; the only change from the
station's earlier vendor-SDK configuration was the transport selection. Both cameras are found by
serial number, the colour comes out right with nothing pinned on the host — no Bayer pattern
override, no mirror or offset written by the host, the camera's declared pixel format taken as is —
and the station's inspection verdicts on its ten taught parts match those recorded before the change.
station's earlier vendor-SDK configuration was the transport selection. Each camera hangs on its own
host NIC and subnet, so the two never share a port — unlike the eight-hour run above — and no
inter-packet delay is needed. Both cameras are found by serial number, the colour comes out right
with nothing pinned on the host — no Bayer pattern override, no mirror or offset written by the
host, the camera's declared pixel format taken as is — and the station's inspection verdicts on its
ten taught parts match those recorded before the change.

What it settles: a second vendor's colour camera works on the declared pattern, as the Basler colour
camera above did on the bench, so both vendors are now covered in monochrome and in colour. What it
does not: this is a functional check, not a soak. No packet or frame statistics were collected, the
endurance figures remain the monochrome pair's, and the firmware version is not yet recorded.
does not: this is a functional check, not a soak. No packet or frame statistics were collected and
the endurance figures remain the monochrome pair's. Firmware is 3.6.2.9 on both cameras, read with
one discovery broadcast (`discover`) while the inspection program was running — discovery does not
open the camera, so the line did not stop for it.
11 changes: 6 additions & 5 deletions docs/sim-register-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,12 @@ Derived nodes without a register: `PayloadSize` (IntSwissKnife `((WIDTH * ((PIXF
invalidated by Width/Height/PixelFormat), `AcquisitionModeIsMultiFrame`, `TriggerModeIsOn`,
`TriggerSoftwareIsAvailable` (IntSwissKnife predicates), `TLParamsLocked` (host-side Integer literal).

Both Converters carry a float literal in `FormulaFrom` on purpose. `TO` is the integer register value and the
formula engine keeps integer ÷ integer as a truncating integer division, so `TO / 10` would read 0.0 dB for
every raw value below 10 and break the write→read round trip. `FormulaTo` receives the Converter's float value
in `FROM`, so it is floating-point already. Both directions are therefore floating-point: a value written
through `Gain` reads back unchanged to 0.1 dB, and `ExposureTime` keeps sub-microsecond raw values.
Both Converters carry a float literal in `FormulaFrom` (`TO / 10.0`). `TO` is the integer register value;
float nodes (SwissKnife, Converter) evaluate `/` as a real division even between two integers (see the
Formula layer in `architecture.md`), so `TO / 10` would read the same today. The literal was written when
the engine still truncated there and is kept so the fixture does not lean on that rule. `FormulaTo` receives
the Converter's float value in `FROM`. Both directions are floating-point: a value written through `Gain`
reads back unchanged to 0.1 dB, and `ExposureTime` keeps sub-microsecond raw values.

## Pixel content

Expand Down
Loading
Loading