diff --git a/Directory.Build.props b/Directory.Build.props
index 6be8bef..948061c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -7,7 +7,7 @@
내지 않는다 — 다음 판에 같이 나간다). 올린 번호는
다시 쓰지 않는다 — NuGet 은 지울 수도 덮어쓸 수도 없다. publish.yml 이 태그와 이 값이 같은지, 태그 커밋이
main 에 있는지 검사하고, 태그가 아닌 ref 에서는 올리지 않는다. -->
- 0.4.0
+ 0.4.1
kintaein
Apache-2.0
diff --git a/README.md b/README.md
index b7f0458..d830cd9 100644
--- a/README.md
+++ b/README.md
@@ -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}");
}
diff --git a/docs/architecture.md b/docs/architecture.md
index b05942d..6b74200 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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 ReceiveAsync(CancellationToken ct = default);
+ public ValueTask 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();
}
@@ -513,9 +514,22 @@ Formula layer (`GenApi/Formula`): `Formula.Parse(string) → Formula` (immutable
`Formula.Evaluate(Func 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 `Node `.
Runtime layer (`GenApi/Runtime`): concrete node classes implementing the public interfaces over the
@@ -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.
@@ -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
diff --git a/docs/design-requirements.md b/docs/design-requirements.md
index e5ce8c8..c2fc509 100644
--- a/docs/design-requirements.md
+++ b/docs/design-requirements.md
@@ -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
@@ -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.
@@ -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
diff --git a/docs/evaluation.md b/docs/evaluation.md
index 6f926d4..d3a6916 100644
--- a/docs/evaluation.md
+++ b/docs/evaluation.md
@@ -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:
@@ -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.
diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md
index caaaada..5ef5153 100644
--- a/docs/sim-register-map.md
+++ b/docs/sim-register-map.md
@@ -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
diff --git a/samples/GevSharp.IpConfig/ViewModels/MainVm.cs b/samples/GevSharp.IpConfig/ViewModels/MainVm.cs
index 60674cb..7175dac 100644
--- a/samples/GevSharp.IpConfig/ViewModels/MainVm.cs
+++ b/samples/GevSharp.IpConfig/ViewModels/MainVm.cs
@@ -160,6 +160,8 @@ public sealed class MainVm : VmBase
private bool _isDhcpSelected;
private readonly Dictionary _connectionNames = new(StringComparer.Ordinal);
private readonly DispatcherTimer _scan;
+ private readonly DispatcherTimer _settle;
+ private Action? _settled;
private bool _autoScan = true;
private bool _isScanning;
@@ -172,8 +174,38 @@ public sealed class MainVm : VmBase
public ObservableCollection Devices { get; } = new();
public ObservableCollection Nics { get; } = new();
- /// 어댑터별로 묶은 장치 목록. 화면의 왼쪽이 이것을 그린다.
- public ObservableCollection Groups { get; } = new();
+ /// 어댑터별로 묶은 장치 목록 — 화면이 읽는 것은 이것을 평평하게 편 뿐이다.
+ private readonly List _groups = new();
+
+ ///
+ /// 화면의 왼쪽이 그리는 목록 하나 — 어댑터 머리 행( ) 뒤에 그 어댑터의 카메라 행( )이
+ /// 이어진다. 어댑터마다 목록을 따로 두고 전부 같은 선택에 묶으면, 한 목록에서 고른 순간 다른 목록들이 "내 것이 아닌 값" 을
+ /// 받아 선택을 비우며 null 을 되밀어 방금 고른 것이 사라지고, 비우지 못한 목록에는 옛 강조가 남아 둘이 골라진 것처럼 보인다.
+ /// 목록이 하나라야 선택도 하나다.
+ ///
+ public ObservableCollection Rows { get; } = new();
+
+ private bool _rebuildingRows;
+
+ ///
+ /// 목록이 고른 행. 카메라면 , 어댑터 머리면 으로 간다 — 둘은 배타라 어느 쪽이든
+ /// 하나만 남는다. 목록을 다시 만드는 동안 들어오는 null 은 무시한다: 지우는 중에 오는 값이지 사람이 고른 것이 아니고,
+ /// 다시 채운 뒤에 같은 카메라를 열쇠(MAC)로 되찾아 고른다. 그 밖의 null 은 사람이 푼 것(Ctrl+클릭)이라 그대로 받는다.
+ ///
+ public object? SelectedRow
+ {
+ get => (object?)_selected ?? _selectedGroup;
+ set
+ {
+ if (_rebuildingRows) return;
+ switch (value)
+ {
+ case DeviceRowVm row: Select(row, loadFields: true); break;
+ case NicGroupVm group: SelectedGroup = group; break;
+ case null: Select(null, loadFields: false); SelectedGroup = null; break;
+ }
+ }
+ }
public DeviceRowVm? Selected
{
@@ -190,6 +222,10 @@ private void Select(DeviceRowVm? value, bool loadFields)
{
{
if (!Set(ref _selected, value, nameof(Selected))) return;
+ // 둘은 배타다. SelectedRow 를 올리기 전에 상대를 비워야 한다 — 올리는 순간 목록이 SelectedRow 를 읽어 가는데,
+ // 그때 옛 상대가 남아 있으면 목록이 그것으로 되돌아가고 사람이 누른 것은 사라진다(실제로 그랬다).
+ if (value is not null) _selectedGroup = null;
+ Raise(nameof(SelectedRow));
Raise(nameof(HasSelection));
Raise(nameof(HasAnySelection));
Raise(nameof(SelectedSummary));
@@ -198,7 +234,6 @@ private void Select(DeviceRowVm? value, bool loadFields)
Raise(nameof(MatchingNic));
Raise(nameof(CanSuggest));
if (value is null) { Firewall = null; return; }
- _selectedGroup = null;
Raise(nameof(SelectedGroup));
Raise(nameof(HasGroupSelection));
Raise(nameof(HasAnySelection));
@@ -209,7 +244,8 @@ private void Select(DeviceRowVm? value, bool loadFields)
// 저장 주소는 사람이 카메라를 고를 때만 읽는다. 검색이 목록을 새로 만들며 같은 카메라를 다시
// 고르는 자리에서까지 열면, 적용 중에 그 검색이 돌 때 우리 세션 둘이 같은 카메라를 동시에 열게 된다 —
// 뒤에 닫히는 쪽이 CCP 를 0 으로 쓰면서 앞 세션의 제어권을 걷어 가고, 적용은 아무것도 못 쓴 채 끝난다.
- if (loadFields) _ = RefreshStoredAsync(value);
+ // 그리고 선택이 잠시 멈춘 뒤에 연다 — 화살표 키로 목록을 훑을 때 행마다 카메라를 열지 않도록.
+ if (loadFields) AfterSettle(() => { if (ReferenceEquals(_selected, value)) _ = RefreshStoredAsync(value); });
}
}
@@ -229,19 +265,38 @@ public NicGroupVm? SelectedGroup
get => _selectedGroup;
set
{
+ // 목록을 다시 만들면 같은 어댑터도 새 객체로 온다. 그때는 선택만 옮기고 이름 칸·방화벽 조회는 건드리지
+ // 않는다 — 매초 도는 재검색마다 PowerShell 을 띄우고 치던 이름을 덮을 이유가 없다.
+ var sameAdapter = value is not null && _selectedGroup is not null
+ && string.Equals(_selectedGroup.Key, value.Key, StringComparison.Ordinal);
if (!Set(ref _selectedGroup, value)) return;
+ // 같은 이유로 카메라를 먼저 비운다(위 Select 참조).
+ if (value is not null) _selected = null;
+ Raise(nameof(SelectedRow));
Raise(nameof(HasGroupSelection));
Raise(nameof(HasAnySelection));
if (value is null) return;
- _selected = null;
Raise(nameof(Selected));
Raise(nameof(HasSelection));
Raise(nameof(HasAnySelection));
+ if (sameAdapter) return;
AdapterName = value.Nic?.Shown ?? "";
- _ = RefreshFirewallAsync(value.Nic);
+ // 방화벽은 PowerShell 을 띄워 묻는다 — 화살표 키로 머리 행을 지나갈 때마다 띄우지 않도록 선택이 멈춘 뒤에 묻는다.
+ AfterSettle(() => { if (ReferenceEquals(_selectedGroup, value)) _ = RefreshFirewallAsync(value.Nic); });
}
}
+ ///
+ /// 선택이 잠시(300 ms) 멈춘 뒤에 할 일. 화살표 키로 목록을 훑으면 행마다 선택이 바뀌는데, 행마다 카메라를 열거나
+ /// PowerShell 을 띄우면 목록이 굼떠진다. 마지막에 멈춘 행의 일만 한다.
+ ///
+ private void AfterSettle(Action action)
+ {
+ _settled = action;
+ _settle.Stop();
+ _settle.Start();
+ }
+
public bool HasGroupSelection => _selectedGroup is not null;
/// 어느 쪽이든 하나는 골랐는지 — 아무것도 안 골랐을 때만 안내를 띄운다.
@@ -346,6 +401,14 @@ public MainVm()
_scan = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_scan.Tick += (_, _) => _ = AutoScanAsync();
_scan.Start();
+ _settle = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
+ _settle.Tick += (_, _) =>
+ {
+ _settle.Stop();
+ var action = _settled;
+ _settled = null;
+ action?.Invoke();
+ };
_ = AutoScanAsync();
}
@@ -987,9 +1050,10 @@ private void Apply(IReadOnlyList found)
// 고르고 있던 카메라가 그대로 있으면 화면은 손대지 않고 객체만 갈아 끼운다. 여기서 장치 값을 다시
// 읽어 넣으면, 방식을 DHCP 에서 고정으로 막 바꿔 놓은 사람의 선택이 다음 검색 한 번에 지워진다.
+ // 아무것도 안 골랐으면 그대로 비워 둔다 — 첫 화면에서 첫 카메라를 대신 골라 주지 않는다. 고른 것이 잠시
+ // 사라진 경우도 손대지 않는다: 다음 검색에 돌아오면 같은 MAC 으로 되찾는다.
var again = keep is null ? null : Devices.FirstOrDefault(d => d.Info.Mac.Equals(keep));
if (again is not null) Select(again, loadFields: false);
- else Selected = Devices.FirstOrDefault();
}
/// 지금 그려 둔 것과 같은지. 장치를 가리는 것은 MAC 이고, 화면에 보이는 것은 주소와 이름이다.
@@ -1026,26 +1090,53 @@ private void Regroup()
var keepGroup = _selectedGroup?.Key;
var typed = _adapterName;
- Groups.Clear();
+ _groups.Clear();
var byInterface = Devices.ToLookup(d => d.Info.InterfaceAddress.ToString());
foreach (var nic in Nics)
{
var mine = nic.Address is null ? new List() : byInterface[nic.Address.ToString()].ToList();
- Groups.Add(new NicGroupVm(nic, nic.Address, mine));
+ _groups.Add(new NicGroupVm(nic, nic.Address, mine));
}
// 어느 어댑터에도 맞지 않는 주소로 들어온 장치 — 어댑터가 그새 사라졌거나 주소가 바뀐 경우다.
var known = Nics.Where(n => n.Address is not null).Select(n => n.Address!.ToString()).ToHashSet(StringComparer.Ordinal);
foreach (var g in byInterface.Where(g => !known.Contains(g.Key)).OrderBy(g => g.Key))
{
- Groups.Add(new NicGroupVm(null, g.First().Info.InterfaceAddress, g.ToList()));
+ _groups.Add(new NicGroupVm(null, g.First().Info.InterfaceAddress, g.ToList()));
}
+ // 화면이 그리는 목록을 다시 채운다. 지우는 동안 목록이 선택을 비우며 null 을 되밀지만 가드가 그것을 버린다 —
+ // 무엇을 다시 고를지는 아래(어댑터)와 Apply(카메라)가 열쇠로 정한다.
+ _rebuildingRows = true;
+ try
+ {
+ Rows.Clear();
+ foreach (var group in _groups)
+ {
+ Rows.Add(group);
+ foreach (var d in group.Devices) Rows.Add(d);
+ }
+ }
+ finally
+ {
+ _rebuildingRows = false;
+ }
+
+ // 다시 채운 목록은 아무것도 고르지 않은 채다. 고른 카메라의 객체가 그대로면(재검색이 아니라 어댑터 이름·상태가
+ // 바뀌어 다시 묶은 경우) 그 자리에서 되살린다 — 아니면 오른쪽은 카메라를 보이는데 목록에는 강조가 없는 채로
+ // 남고, 재검색은 장치가 그대로라 아무것도 하지 않아 영영 그대로다. 어댑터는 아래에서 열쇠로 되찾는다.
+ if (_selected is not null) Raise(nameof(SelectedRow));
+
if (keepGroup is null) return;
- var again = Groups.FirstOrDefault(g => string.Equals(g.Key, keepGroup, StringComparison.Ordinal));
- if (again is null) return;
+ var again = _groups.FirstOrDefault(g => string.Equals(g.Key, keepGroup, StringComparison.Ordinal));
+ if (again is null)
+ {
+ // 고른 어댑터가 사라졌다 — 오른쪽에 없는 어댑터를 계속 보이지 않는다.
+ SelectedGroup = null;
+ return;
+ }
SelectedGroup = again;
diff --git a/samples/GevSharp.IpConfig/Views/MainWindow.axaml b/samples/GevSharp.IpConfig/Views/MainWindow.axaml
index fc9cf17..388b496 100644
--- a/samples/GevSharp.IpConfig/Views/MainWindow.axaml
+++ b/samples/GevSharp.IpConfig/Views/MainWindow.axaml
@@ -61,44 +61,37 @@
ToolTip.Tip="Keep looking once a second, so an address change shows up on its own" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/GevSharp.IpConfig/Views/MainWindow.axaml.cs b/samples/GevSharp.IpConfig/Views/MainWindow.axaml.cs
index 580f695..2444637 100644
--- a/samples/GevSharp.IpConfig/Views/MainWindow.axaml.cs
+++ b/samples/GevSharp.IpConfig/Views/MainWindow.axaml.cs
@@ -1,5 +1,5 @@
+using System.ComponentModel;
using Avalonia.Controls;
-using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using GevSharp.IpConfig.ViewModels;
@@ -11,20 +11,52 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
+ DataContextChanged += (_, _) => HookSelection();
+ HookSelection();
}
- private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+ private MainVm? _hooked;
- private MainVm? Vm => DataContext as MainVm;
+ private void HookSelection()
+ {
+ if (_hooked is not null) _hooked.PropertyChanged -= OnVmPropertyChanged;
+ _hooked = Vm;
+ if (_hooked is null) return;
+ _hooked.PropertyChanged += OnVmPropertyChanged;
+ SyncSelectedRow(retry: true);
+ }
- private void OnScan(object? sender, RoutedEventArgs e) => _ = Vm?.ScanAsync();
+ private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(MainVm.SelectedRow)) SyncSelectedRow(retry: true);
+ }
- // 어댑터 머리를 누르면 오른쪽이 호스트 쪽 이야기로 바뀐다.
- private void OnAdapterPressed(object? sender, PointerPressedEventArgs e)
+ ///
+ /// VM 이 고른 행을 목록에 그대로 옮긴다. SelectedItem 의 두 방향 바인딩은 목록→VM 은 되지만, 목록을 비웠다 다시 채운 뒤
+ /// 같은 값을 다시 올리는 경우에는 VM→목록이 오지 않는다(실측: 목록이 그 항목을 갖고 VM 도 골랐는데 SelectedIndex 가
+ /// -1 에 머물렀고, 직접 넣으면 바로 선택됐다). 그래서 바인딩은 목록→VM 한 방향만 두고 이 방향은 여기서 직접 한다.
+ /// 같은 행이면 손대지 않아 되돌이가 없다.
+ ///
+ /// 목록을 방금 새로 채운 같은 호출 안에서는 넣은 값이 붙지 않는다. 붙지 않았으면 한 차례 뒤에 딱 한 번 더 넣는다 —
+ /// 그때는 목록이 항목을 다 받아들인 뒤라 붙는다. 그래도 안 붙으면(그 행이 목록에 없다) 거기서 그만둔다.
+ ///
+ ///
+ private void SyncSelectedRow(bool retry)
{
- if ((sender as Control)?.DataContext is NicGroupVm group && Vm is { } vm) vm.SelectedGroup = group;
+ if (Vm is not { } vm || this.FindControl("DeviceList") is not { } list) return;
+ var row = vm.SelectedRow;
+ if (ReferenceEquals(list.SelectedItem, row)) return;
+ list.SelectedItem = row;
+ if (retry && !ReferenceEquals(list.SelectedItem, row))
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => SyncSelectedRow(retry: false), Avalonia.Threading.DispatcherPriority.Background);
}
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private MainVm? Vm => DataContext as MainVm;
+
+ private void OnScan(object? sender, RoutedEventArgs e) => _ = Vm?.ScanAsync();
+
private void OnSuggest(object? sender, RoutedEventArgs e) => Vm?.Suggest();
private void OnSetName(object? sender, RoutedEventArgs e) => _ = Vm?.ApplyUserNameAsync();
diff --git a/src/GevSharp/GenApi/Formula/Formula.cs b/src/GevSharp/GenApi/Formula/Formula.cs
index b73e6a9..e73c58e 100644
--- a/src/GevSharp/GenApi/Formula/Formula.cs
+++ b/src/GevSharp/GenApi/Formula/Formula.cs
@@ -19,6 +19,12 @@ namespace GevSharp.GenApi;
/// 비교·논리는 정수 1/0, 실수의 비트 연산은 오류. 0 나눗셈·오버플로·초월함수의 정의역 밖(SQRT(-1), LN(0))·모르는 변수/함수·
/// 문법 오류는 위치(0 기준 문자 인덱스)를 담은 — 결과를 0 이나 NaN 으로 흘리지 않는다.
///
+///
+/// 위 형 규칙은 이 클래스의 공개 평가 메서드와 정수 노드(IntSwissKnife·IntConverter)의 것이다. 노드맵 안의 실수 노드(SwissKnife·Converter)는
+/// 값이 실수이므로 같은 수식을 실수 규칙으로 평가한다 — / 는 정수끼리도 실수 결과, 정수 넘침은 실수로 계속,
+/// 비트·시프트·% 는 실수 피연산자를 0 방향으로 잘라 정수로 계산한다. ** 는 두 규칙 모두 0 의 음수 거듭제곱과
+/// 정의되지 않는 결과(음수의 분수 거듭제곱)를 NaN·무한대 대신 예외로 낸다.
+///
/// 변수 이름은 대소문자를 구분하며 글자·숫자·'_'·'.' 로 이루어진다. 값은 호출자가 이름으로 공급한다(pVariable → 노드 매핑은 노드 계층 몫).
///
public sealed class Formula
@@ -60,14 +66,18 @@ public static Formula Parse(string text)
public GenApiValue Evaluate(Func resolve)
{
if (resolve is null) throw new ArgumentNullException(nameof(resolve));
- return _root.Eval(new FormulaEvalCtx(Text, _vars, resolve));
+ return _root.Eval(new FormulaEvalCtx(Text, _vars, resolve, FormulaMode.Integer));
}
/// 사전으로 평가. 실제로 쓰이는 변수가 사전에 없으면 위치를 담은 .
public GenApiValue Evaluate(IReadOnlyDictionary variables)
+ => Evaluate(variables, FormulaMode.Integer);
+
+ /// 사전으로 평가하되 평가 규칙을 고른다.
+ internal GenApiValue Evaluate(IReadOnlyDictionary variables, FormulaMode mode)
{
if (variables is null) throw new ArgumentNullException(nameof(variables));
- var ctx = new FormulaEvalCtx(Text, _vars, null);
+ var ctx = new FormulaEvalCtx(Text, _vars, null, mode);
for (int i = 0; i < _vars.Length; i++)
{
if (variables.TryGetValue(_vars[i], out var v)) ctx.SetVariable(i, v);
@@ -80,14 +90,18 @@ public GenApiValue Evaluate(IReadOnlyDictionary variables)
/// 동기로 평가한다. 삼항의 택하지 않은 가지 변수도 해석된다는 점이 동기 평가와 다르다.
///
public ValueTask EvaluateAsync(Func> resolve, CancellationToken ct = default)
+ => EvaluateAsync(resolve, FormulaMode.Integer, ct);
+
+ /// 비동기 평가하되 평가 규칙을 고른다 — 노드맵의 수식 노드가 자기 종류(정수/실수)에 맞춰 부른다.
+ internal ValueTask EvaluateAsync(Func> resolve, FormulaMode mode, CancellationToken ct)
{
if (resolve is null) throw new ArgumentNullException(nameof(resolve));
- return EvaluateAsyncCore(resolve, ct);
+ return EvaluateAsyncCore(resolve, mode, ct);
}
- private async ValueTask EvaluateAsyncCore(Func> resolve, CancellationToken ct)
+ private async ValueTask EvaluateAsyncCore(Func> resolve, FormulaMode mode, CancellationToken ct)
{
- var ctx = new FormulaEvalCtx(Text, _vars, null);
+ var ctx = new FormulaEvalCtx(Text, _vars, null, mode);
for (int i = 0; i < _vars.Length; i++)
{
ct.ThrowIfCancellationRequested();
diff --git a/src/GevSharp/GenApi/Formula/FormulaNode.cs b/src/GevSharp/GenApi/Formula/FormulaNode.cs
index 61ab556..0c0d04e 100644
--- a/src/GevSharp/GenApi/Formula/FormulaNode.cs
+++ b/src/GevSharp/GenApi/Formula/FormulaNode.cs
@@ -32,15 +32,19 @@ internal sealed class FormulaEvalCtx
private readonly bool[] _isResolved;
private readonly Func? _resolve;
- public FormulaEvalCtx(string text, string[] names, Func? resolve)
+ public FormulaEvalCtx(string text, string[] names, Func? resolve, FormulaMode mode)
{
_text = text;
_names = names;
_values = names.Length == 0 ? Array.Empty() : new GenApiValue[names.Length];
_isResolved = names.Length == 0 ? Array.Empty() : new bool[names.Length];
_resolve = resolve;
+ IsReal = mode == FormulaMode.Real;
}
+ /// 로 평가하는지 — 연산자마다 에 그대로 넘긴다.
+ public bool IsReal { get; }
+
public FormulaErrSite Site(int pos) => new(_text, pos);
public void SetVariable(int index, GenApiValue value)
@@ -121,9 +125,9 @@ public override GenApiValue Eval(FormulaEvalCtx ctx)
var v = _operand.Eval(ctx);
return _op switch
{
- FormulaUnOp.Neg => FormulaOps.Negate(v, ctx.Site(Pos)),
+ FormulaUnOp.Neg => FormulaOps.Negate(v, ctx.Site(Pos), ctx.IsReal),
FormulaUnOp.LogNot => GenApiValue.FromBoolean(!v.IsNonZero),
- FormulaUnOp.BitNot => FormulaOps.BitNot(v, ctx.Site(Pos)),
+ FormulaUnOp.BitNot => FormulaOps.BitNot(v, ctx.Site(Pos), ctx.IsReal),
_ => throw new ArgumentOutOfRangeException(nameof(_op)),
};
}
@@ -157,19 +161,20 @@ public override GenApiValue Eval(FormulaEvalCtx ctx)
var l = _left.Eval(ctx);
var r = _right.Eval(ctx);
var site = ctx.Site(Pos);
+ var isReal = ctx.IsReal;
return _op switch
{
- FormulaBinOp.Add => FormulaOps.Add(l, r, site),
- FormulaBinOp.Sub => FormulaOps.Subtract(l, r, site),
- FormulaBinOp.Mul => FormulaOps.Multiply(l, r, site),
- FormulaBinOp.Div => FormulaOps.Divide(l, r, site),
- FormulaBinOp.Mod => FormulaOps.Modulo(l, r, site),
- FormulaBinOp.Pow => FormulaOps.Pow(l, r, site),
- FormulaBinOp.BitAnd => FormulaOps.BitAnd(l, r, site),
- FormulaBinOp.BitOr => FormulaOps.BitOr(l, r, site),
- FormulaBinOp.BitXor => FormulaOps.BitXor(l, r, site),
- FormulaBinOp.Shl => FormulaOps.ShiftLeft(l, r, site),
- FormulaBinOp.Shr => FormulaOps.ShiftRight(l, r, site),
+ FormulaBinOp.Add => FormulaOps.Add(l, r, site, isReal),
+ FormulaBinOp.Sub => FormulaOps.Subtract(l, r, site, isReal),
+ FormulaBinOp.Mul => FormulaOps.Multiply(l, r, site, isReal),
+ FormulaBinOp.Div => FormulaOps.Divide(l, r, site, isReal),
+ FormulaBinOp.Mod => FormulaOps.Modulo(l, r, site, isReal),
+ FormulaBinOp.Pow => FormulaOps.Pow(l, r, site, isReal),
+ FormulaBinOp.BitAnd => FormulaOps.BitAnd(l, r, site, isReal),
+ FormulaBinOp.BitOr => FormulaOps.BitOr(l, r, site, isReal),
+ FormulaBinOp.BitXor => FormulaOps.BitXor(l, r, site, isReal),
+ FormulaBinOp.Shl => FormulaOps.ShiftLeft(l, r, site, isReal),
+ FormulaBinOp.Shr => FormulaOps.ShiftRight(l, r, site, isReal),
FormulaBinOp.Eq or FormulaBinOp.Ne or FormulaBinOp.Lt or FormulaBinOp.Le or FormulaBinOp.Gt or FormulaBinOp.Ge
=> FormulaOps.Compare(_op, l, r),
_ => throw new ArgumentOutOfRangeException(nameof(_op)),
@@ -215,7 +220,7 @@ public override GenApiValue Eval(FormulaEvalCtx ctx)
{
var x = _arg.Eval(ctx);
return _arg2 is null
- ? FormulaOps.Call(_func, x, ctx.Site(Pos))
+ ? FormulaOps.Call(_func, x, ctx.Site(Pos), ctx.IsReal)
: FormulaOps.Call(_func, x, _arg2.Eval(ctx), ctx.Site(Pos));
}
}
diff --git a/src/GevSharp/GenApi/Formula/FormulaOps.cs b/src/GevSharp/GenApi/Formula/FormulaOps.cs
index 2ccfa7a..3f2245f 100644
--- a/src/GevSharp/GenApi/Formula/FormulaOps.cs
+++ b/src/GevSharp/GenApi/Formula/FormulaOps.cs
@@ -20,10 +20,33 @@ public FormulaErrSite(string? text, int pos)
public GenApiException Fail(string message) => new(FormulaOps.Describe(message, Text, Pos));
}
+///
+/// 수식 평가 규칙 — 어느 노드의 수식이냐가 정한다. 값의 종류(정수/실수)는 두 규칙 모두 피연산자에서 오고, 갈리는 것은
+/// 정수끼리의 연산이 정수 결과를 고집하느냐다.
+///
+internal enum FormulaMode
+{
+ ///
+ /// 정수 노드(IntSwissKnife·IntConverter·주소 수식)와 공개 :
+ /// 정수끼리는 정수(/ 는 0 방향 절삭, 넘침은 예외), 실수가 섞이면 실수, 실수의 비트 연산은 예외.
+ ///
+ Integer,
+
+ ///
+ /// 실수 노드(SwissKnife·Converter): 값이 실수이므로 / 는 정수끼리여도 실수 결과를 낸다(10/4 = 2.5).
+ /// 정수끼리의 + - * ** (0 이상 지수)는 정확하므로 정수로 두되 넘치면 예외 대신 실수로 계속한다. 비트·시프트·나머지(% )는
+ /// 정수 연산이라 실수 피연산자를 0 방향으로 잘라 정수로 바꾼다 — 나눗셈이 실수가 되면서 (N / 2) & 1 ·(N / 2) % 2 같은 식이
+ /// 예외로 깨지거나 분수가 되지 않게 하려는 것이고, 정수 나눗셈 뒤 그 연산을 하던 결과와 같다(부호와 무관하게, 피연산자가 2^53 이하일 때 —
+ /// 그보다 크면 실수 변환에서 이미 반올림된다).
+ ///
+ Real,
+}
+
///
/// 수식 연산의 실제 의미 — 정수/실수 승격 규칙과 오류 판정을 한 곳에 모은다.
/// 의 연산자와 수식 트리 평가가 모두 여기를 거친다.
-/// 정수끼리는 정수(오버플로는 예외), 한쪽이라도 실수면 실수. 0 나눗셈은 정수·실수 모두 예외.
+/// 기본( )은 정수끼리는 정수(오버플로는 예외), 한쪽이라도 실수면 실수. 0 나눗셈은 정수·실수 모두 예외.
+/// isReal 이 참이면 규칙이다.
/// 초월함수의 정의역 밖 인자(SQRT(-1), LN(0), ACOS(2) 등)도 예외 — NaN·무한대를 값으로 흘리지 않는다.
///
internal static class FormulaOps
@@ -76,42 +99,45 @@ public static long ToInt64(double value, FormulaErrSite site)
return (long)t;
}
- // ---- 산술 ----
+ // ---- 산술 — 정수 넘침은 정수 규칙에서 예외, 실수 규칙에서는 실수로 계속한다 ----
- public static GenApiValue Add(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ public static GenApiValue Add(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
if (a.IsInteger && b.IsInteger)
{
try { return checked(a.AsInt64 + b.AsInt64); }
+ catch (OverflowException) when (isReal) { }
catch (OverflowException) { throw site.Fail("Integer overflow in '+'"); }
}
return a.AsDouble + b.AsDouble;
}
- public static GenApiValue Subtract(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ public static GenApiValue Subtract(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
if (a.IsInteger && b.IsInteger)
{
try { return checked(a.AsInt64 - b.AsInt64); }
+ catch (OverflowException) when (isReal) { }
catch (OverflowException) { throw site.Fail("Integer overflow in '-'"); }
}
return a.AsDouble - b.AsDouble;
}
- public static GenApiValue Multiply(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ public static GenApiValue Multiply(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
if (a.IsInteger && b.IsInteger)
{
try { return checked(a.AsInt64 * b.AsInt64); }
+ catch (OverflowException) when (isReal) { }
catch (OverflowException) { throw site.Fail("Integer overflow in '*'"); }
}
return a.AsDouble * b.AsDouble;
}
- /// 나눗셈 — 정수끼리는 0 방향 절삭. 0 으로 나누면 정수·실수 모두 예외.
- public static GenApiValue Divide(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ /// 나눗셈 — 정수 규칙에서 정수끼리는 0 방향 절삭, 실수 규칙에서는 항상 실수 나눗셈. 0 으로 나누면 어느 쪽이든 예외.
+ public static GenApiValue Divide(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
- if (a.IsInteger && b.IsInteger)
+ if (a.IsInteger && b.IsInteger && !isReal)
{
long x = a.AsInt64, y = b.AsInt64;
if (y == 0) throw site.Fail("Division by zero");
@@ -123,23 +149,35 @@ public static GenApiValue Divide(GenApiValue a, GenApiValue b, FormulaErrSite si
return a.AsDouble / d;
}
- /// 나머지 — 정수는 피제수 부호를 따르고, 실수는 절삭 나눗셈 기반 나머지(C 의 fmod).
- public static GenApiValue Modulo(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ ///
+ /// 나머지 — 정수는 피제수 부호를 따른다. 실수 피연산자는 정수 규칙에서 절삭 나눗셈 기반 나머지(C 의 fmod), 실수 규칙에서는
+ /// 비트 연산처럼 0 방향으로 잘라 정수 나머지를 낸다 — 나눗셈이 실수가 되면서 (N / 2) % 2 가 1.5 같은 값으로 바뀌지 않게 한다
+ /// (정수 나눗셈 뒤 나머지와 같은 답). NaN·64비트 범위 밖은 자르지 않고 예외.
+ ///
+ public static GenApiValue Modulo(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
- if (a.IsInteger && b.IsInteger)
- {
- long x = a.AsInt64, y = b.AsInt64;
- if (y == 0) throw site.Fail("Division by zero in '%'");
- if (y == -1) return 0L; // MinValue % -1 의 오버플로 회피 — 수학적으로 0
- return x % y;
- }
+ if (a.IsInteger && b.IsInteger) return IntRemainder(a.AsInt64, b.AsInt64, site);
+ if (isReal) return IntRemainder(BitOperand(a, "%", true, site), BitOperand(b, "%", true, site), site);
double d = b.AsDouble;
if (d == 0.0) throw site.Fail("Division by zero in '%'");
return a.AsDouble % d;
}
- /// 거듭제곱. 정수 밑·0 이상 정수 지수는 정확한 정수(오버플로는 예외); 음수 지수나 실수 피연산자는 실수.
- public static GenApiValue Pow(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ private static GenApiValue IntRemainder(long x, long y, FormulaErrSite site)
+ {
+ if (y == 0) throw site.Fail("Division by zero in '%'");
+ if (y == -1) return 0L; // MinValue % -1 의 오버플로 회피 — 수학적으로 0
+ return x % y;
+ }
+
+ ///
+ /// 거듭제곱. 정수 밑·0 이상 정수 지수는 정확한 정수(넘침은 정수 규칙에서 예외, 실수 규칙에서 실수); 음수 지수나 실수 피연산자는 실수.
+ /// 정수 결과는 같은 값의 실수 결과와 같으므로(넘치지 않는 한) 실수 규칙에서도 정확한 정수를 먼저 쓴다.
+ /// 0 의 음수 거듭제곱은 정수·실수 모두 0 나눗셈 예외, 실수 결과가 정의되지 않으면(음수의 분수 거듭제곱) NaN 대신 예외 —
+ /// 실수 규칙에서는 지수가 나눗셈에서 실수로 오므로 정수 지수만 막아서는 새어 나간다. 실수 범위를 넘는 크기는 무한대로 남는다
+ /// (다른 실수 산술과 같다; Converter 한계 계산은 그 끝을 "한계 없음" 으로 읽는다).
+ ///
+ public static GenApiValue Pow(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
if (a.IsInteger && b.IsInteger)
{
@@ -150,9 +188,15 @@ public static GenApiValue Pow(GenApiValue a, GenApiValue b, FormulaErrSite site)
return Math.Pow(x, y);
}
try { return IntPow(x, y); }
+ catch (OverflowException) when (isReal) { return Math.Pow(x, y); }
catch (OverflowException) { throw site.Fail("Integer overflow in '**'"); }
}
- return Math.Pow(a.AsDouble, b.AsDouble);
+ double xb = a.AsDouble, yb = b.AsDouble;
+ if (xb == 0.0 && yb < 0) throw site.Fail("Division by zero in '**'");
+ var r = Math.Pow(xb, yb);
+ if (double.IsNaN(r) && !double.IsNaN(xb) && !double.IsNaN(yb))
+ throw site.Fail("'**' is undefined for the base " + Fmt(xb) + " and the exponent " + Fmt(yb));
+ return r;
}
/// 제곱 반복으로 정확한 정수 거듭제곱. 오버플로는 OverflowException 으로 나온다.
@@ -168,68 +212,72 @@ private static long IntPow(long b, long e)
return result;
}
- public static GenApiValue Negate(GenApiValue a, FormulaErrSite site)
+ public static GenApiValue Negate(GenApiValue a, FormulaErrSite site, bool isReal = false)
{
if (a.IsInteger)
{
long x = a.AsInt64;
- if (x == long.MinValue) throw site.Fail("Integer overflow in unary '-'");
- return -x;
+ if (x != long.MinValue) return -x;
+ if (!isReal) throw site.Fail("Integer overflow in unary '-'");
}
return -a.AsDouble;
}
- // ---- 비트 연산: 정수 전용 ----
+ // ---- 비트 연산: 정수 연산 — 정수 규칙은 실수 피연산자를 거절하고, 실수 규칙은 0 방향으로 잘라 정수로 바꾼다 ----
- private static void RequireIntegers(GenApiValue a, GenApiValue b, string op, FormulaErrSite site)
+ ///
+ /// 비트 연산의 피연산자를 정수로. 정수는 그대로, 실수는 정수 규칙이면 예외, 실수 규칙이면 0 방향 절삭
+ /// (NaN·무한대·64비트 범위 밖은 예외 — 자를 수 없는 값을 0 으로 흘리지 않는다).
+ ///
+ private static long BitOperand(GenApiValue v, string op, bool isReal, FormulaErrSite site)
{
- if (!a.IsInteger || !b.IsInteger)
- throw site.Fail("Operator '" + op + "' requires integer operands");
+ if (v.IsInteger) return v.AsInt64;
+ if (!isReal) throw site.Fail("Operator '" + op + "' requires integer operands");
+ return ToInt64(v.AsDouble, site);
}
- public static GenApiValue BitAnd(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ public static GenApiValue BitAnd(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
- RequireIntegers(a, b, "&", site);
- return a.AsInt64 & b.AsInt64;
+ long x = BitOperand(a, "&", isReal, site), y = BitOperand(b, "&", isReal, site);
+ return x & y;
}
- public static GenApiValue BitOr(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ public static GenApiValue BitOr(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
- RequireIntegers(a, b, "|", site);
- return a.AsInt64 | b.AsInt64;
+ long x = BitOperand(a, "|", isReal, site), y = BitOperand(b, "|", isReal, site);
+ return x | y;
}
- public static GenApiValue BitXor(GenApiValue a, GenApiValue b, FormulaErrSite site)
+ public static GenApiValue BitXor(GenApiValue a, GenApiValue b, FormulaErrSite site, bool isReal = false)
{
- RequireIntegers(a, b, "^", site);
- return a.AsInt64 ^ b.AsInt64;
+ long x = BitOperand(a, "^", isReal, site), y = BitOperand(b, "^", isReal, site);
+ return x ^ y;
}
- public static GenApiValue BitNot(GenApiValue a, FormulaErrSite site)
+ public static GenApiValue BitNot(GenApiValue a, FormulaErrSite site, bool isReal = false)
{
- if (!a.IsInteger) throw site.Fail("Operator '~' requires an integer operand");
- return ~a.AsInt64;
+ if (!a.IsInteger && !isReal) throw site.Fail("Operator '~' requires an integer operand");
+ return ~BitOperand(a, "~", isReal, site);
}
- private static int ShiftCount(GenApiValue count, string op, FormulaErrSite site)
+ private static int ShiftCount(long n, string op, FormulaErrSite site)
{
- long n = count.AsInt64;
if (n < 0 || n > 63)
throw site.Fail("Shift count " + n.ToString(CultureInfo.InvariantCulture) + " in '" + op + "' is outside 0..63");
return (int)n;
}
- public static GenApiValue ShiftLeft(GenApiValue a, GenApiValue count, FormulaErrSite site)
+ public static GenApiValue ShiftLeft(GenApiValue a, GenApiValue count, FormulaErrSite site, bool isReal = false)
{
- RequireIntegers(a, count, "<<", site);
- return a.AsInt64 << ShiftCount(count, "<<", site);
+ long x = BitOperand(a, "<<", isReal, site), n = BitOperand(count, "<<", isReal, site);
+ return x << ShiftCount(n, "<<", site);
}
/// 산술(부호 유지) 오른쪽 시프트.
- public static GenApiValue ShiftRight(GenApiValue a, GenApiValue count, FormulaErrSite site)
+ public static GenApiValue ShiftRight(GenApiValue a, GenApiValue count, FormulaErrSite site, bool isReal = false)
{
- RequireIntegers(a, count, ">>", site);
- return a.AsInt64 >> ShiftCount(count, ">>", site);
+ long x = BitOperand(a, ">>", isReal, site), n = BitOperand(count, ">>", isReal, site);
+ return x >> ShiftCount(n, ">>", site);
}
// ---- 비교: 정수끼리는 정수 비교, 아니면 실수 비교(IEEE — NaN 은 모든 순서 비교에서 거짓). 결과는 정수 1/0. ----
@@ -275,7 +323,7 @@ public static GenApiValue Compare(FormulaBinOp op, GenApiValue a, GenApiValue b)
/// 나머지(삼각·지수·로그·제곱근)는 항상 실수. 정의역 밖 인자(SQRT 의 음수, LN·LG 의 0 이하, ASIN·ACOS 의 -1..1 밖)는
/// NaN·무한대 대신 위치를 담은 예외.
///
- public static GenApiValue Call(FormulaFunc func, GenApiValue x, FormulaErrSite site)
+ public static GenApiValue Call(FormulaFunc func, GenApiValue x, FormulaErrSite site, bool isReal = false)
{
switch (func)
{
@@ -299,13 +347,13 @@ public static GenApiValue Call(FormulaFunc func, GenApiValue x, FormulaErrSite s
if (x.IsInteger)
{
long v = x.AsInt64;
- if (v == long.MinValue) throw site.Fail("Integer overflow in ABS");
- return v < 0 ? -v : v;
+ if (v != long.MinValue) return v < 0 ? -v : v;
+ if (!isReal) throw site.Fail("Integer overflow in ABS");
}
return Math.Abs(x.AsDouble);
case FormulaFunc.Neg:
- return Negate(x, site);
+ return Negate(x, site, isReal);
case FormulaFunc.Sgn:
if (x.IsInteger) return (long)Math.Sign(x.AsInt64);
diff --git a/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs b/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs
index 0c3f2e3..5714503 100644
--- a/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs
+++ b/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs
@@ -13,6 +13,7 @@ namespace GevSharp.GenApi;
/// 무효화: 노드 X 가 쓰이면 X 를 p* 로 참조하는 노드, X 를 pInvalidator 로 지목한 노드, X 가 셀렉터일 때 pSelected 대상 — 그리고 그들에게서
/// 같은 규칙으로 닿는 노드 전부 — 의 캐시를 버린다(값 사슬 아래의 레지스터 캐시까지, pIndex 슬롯 전부 포함). 쓰인 레지스터 자신의 캐시는
/// Cachable 정책이 정한다(WriteThrough 는 쓴 값을 남긴다). 는 쓰기 없이 같은 전파를 하되 자기 자신도 버린다.
+/// 쓰기가 예외로 끝나면 장치가 값을 받았는지 모르므로(명령은 응답 전에 이미 나간다) 와 같이 자기 자신까지 버린다.
///
///
/// 쓰기 그림자( ): 포트에 쓴 바이트를 주소별로 기억해 쓰기 전용 레지스터의 읽기-수정-쓰기 바탕값으로 쓴다.
@@ -100,6 +101,24 @@ internal void OnRegisterWritten(RegisterCore core, ulong address, byte[] data)
}
}
+ ///
+ /// 레지스터 쓰기가 실패했다 — 장치가 그 바이트를 받았는지 모르므로 주소가 겹치는 다른 노드의 캐시를 버린다.
+ /// 그림자는 그대로 둔다( 참고).
+ ///
+ internal void OnRegisterWriteFailed(RegisterCore core, ulong address, int length)
+ {
+ foreach (var c in _cores)
+ {
+ if (!ReferenceEquals(c, core)) c.DropIfOverlaps(address, length);
+ }
+ }
+
+ ///
+ /// 노드 쓰기가 도중에 실패했다 — 값 사슬 어디까지 장치에 닿았는지 모르므로 성공 때( )와 달리 노드 자신과
+ /// 값 사슬까지 포함해 의존 닫힘 전체를 버린다( 와 같다). 틀려도 비용은 다음 읽기뿐이다.
+ ///
+ internal void OnWriteFailed(NodeBase node) => InvalidateNode(node);
+
/// — 노드 자신과 값 사슬, 그리고 의존 닫힘 전체의 캐시를 버린다.
internal void InvalidateNode(NodeBase node)
{
diff --git a/src/GevSharp/GenApi/Runtime/FloatNodes.cs b/src/GevSharp/GenApi/Runtime/FloatNodes.cs
index dffb3f1..a99589d 100644
--- a/src/GevSharp/GenApi/Runtime/FloatNodes.cs
+++ b/src/GevSharp/GenApi/Runtime/FloatNodes.cs
@@ -43,7 +43,15 @@ public async ValueTask SetAsync(double value, CancellationToken ct = default)
internal async ValueTask WriteDoubleAsync(double value, CancellationToken ct)
{
await ValidateAsync(value, ct).ConfigureAwait(false);
- await WriteCoreAsync(value, ct).ConfigureAwait(false);
+ try
+ {
+ await WriteCoreAsync(value, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
@@ -334,7 +342,10 @@ public override async ValueTask GetMaxAsync(CancellationToken ct = defau
public override ValueTask GetIncAsync(CancellationToken ct = default) => new((double?)null);
}
-/// <SwissKnife> — 실수 수식. 읽기 전용.
+///
+/// <SwissKnife> — 실수 수식. 읽기 전용. 변수가 정수 레지스터에서 와도 실수 규칙( )으로 평가한다 —
+/// 정수끼리의 나눗셈을 자르면 1000000 / N 같은 프레임률이 정수로 떨어지고 dB 변환은 한 구간이 통째로 1.0 이 된다.
+///
internal sealed class SwissKnifeNode : FloatNodeBase
{
private readonly SwissKnifeDef _def;
@@ -350,7 +361,7 @@ public SwissKnifeNode(SwissKnifeDef def, GenApiNodeMap map) : base(def, map)
protected override void BindCore(NodeBinder binder)
{
- _scope = new FormulaScope(this, _def, binder);
+ _scope = new FormulaScope(this, _def, binder, FormulaMode.Real);
_formula = _scope.Parse(_def.Formula, "Formula");
}
@@ -368,6 +379,7 @@ protected override ValueTask WriteCoreAsync(double value, CancellationToken ct)
///
/// <Converter> — pValue 노드와의 실수 양방향 변환. 읽기는 FormulaFrom(TO = 대상 값), 쓰기는 FormulaTo(FROM = 호스트 값);
/// 대상이 정수 노드면 쓰기 결과를 반올림해 넣는다. Min/Max 는 Slope 로 정하고 Inc 는 없다.
+/// 두 방향 수식과 한계 계산 모두 실수 규칙( )으로 평가한다 — TO 는 대개 정수 레지스터 값이다.
///
internal sealed class ConverterNode : FloatNodeBase
{
@@ -388,7 +400,7 @@ protected override void BindCore(NodeBinder binder)
{
if (_def.PValue is null) throw new GenApiException($"Converter '{Name}' has no pValue.", Name);
_pValue = binder.Resolve(_def.PValue, RefKind.Value, "pValue", NodeBinder.Numeric);
- _scope = new FormulaScope(this, _def, binder);
+ _scope = new FormulaScope(this, _def, binder, FormulaMode.Real);
_to = _scope.Parse(_def.FormulaTo, "FormulaTo");
_from = _scope.Parse(_def.FormulaFrom, "FormulaFrom");
}
diff --git a/src/GevSharp/GenApi/Runtime/FormulaScope.cs b/src/GevSharp/GenApi/Runtime/FormulaScope.cs
index f92d37b..1f216dc 100644
--- a/src/GevSharp/GenApi/Runtime/FormulaScope.cs
+++ b/src/GevSharp/GenApi/Runtime/FormulaScope.cs
@@ -6,8 +6,10 @@ namespace GevSharp.GenApi.Runtime;
/// 수식 노드(SwissKnife/IntSwissKnife/Converter/IntConverter)의 변수 범위 — pVariable·Constant·Expression 을 이름으로 묶고
/// 수식을 비동기로 평가한다. 수식은 바인딩 시점에 한 번만 파싱한다.
///
-/// 평가는 로 한다: 수식이 나열한 변수를 전부(택하지 않은 삼항 가지의 것까지) 먼저 읽고
-/// 동기로 계산한다. 변수 하나의 읽기 실패가 곧 수식 실패라 결과가 결정적이고, 레지스터 왕복이 변수 순서대로 한 번씩만 일어난다.
+/// 평가는 로 한다:
+/// 수식이 나열한 변수를 전부(택하지 않은 삼항 가지의 것까지) 먼저 읽고 동기로 계산한다. 변수 하나의 읽기 실패가 곧 수식 실패라
+/// 결과가 결정적이고, 레지스터 왕복이 변수 순서대로 한 번씩만 일어난다. 평가 규칙( )은 소유 노드의
+/// 종류가 정하며 이 범위의 모든 수식(본식·Expression·Converter 한계 계산)에 같이 쓰인다.
///
///
/// pVariable 의 Name 은 수식 안의 변수 이름 그대로이며, 점 접미사로 무엇을 읽을지 정한다:
@@ -43,13 +45,19 @@ public VarRef(NodeBase node, VarSuffix suffix, GenApiValue constant)
}
private readonly NodeBase _owner;
+ private readonly FormulaMode _mode;
private readonly Dictionary _constants = new(StringComparer.Ordinal);
private readonly Dictionary _expressions = new(StringComparer.Ordinal);
private readonly Dictionary _variables = new(StringComparer.Ordinal);
- public FormulaScope(NodeBase owner, IFormulaNodeDef def, NodeBinder binder)
+ ///
+ /// 평가 규칙은 필수 인자다 — 실수 노드는 , 정수 노드는 .
+ /// 기본값을 두지 않아 새 수식 노드가 규칙을 고르지 않고 지나가지 못하게 한다.
+ ///
+ public FormulaScope(NodeBase owner, IFormulaNodeDef def, NodeBinder binder, FormulaMode mode)
{
_owner = owner;
+ _mode = mode;
foreach (var c in def.Constants)
_constants[c.Name] = c.IntValue is { } iv ? new GenApiValue(iv) : new GenApiValue(c.DoubleValue);
foreach (var v in def.Variables)
@@ -74,7 +82,7 @@ public Formula Parse(string text, string label)
/// 수식을 평가한다. extraName 은 Converter 의 FROM/TO 처럼 호출자가 값을 주는 변수.
public ValueTask EvaluateAsync(Formula formula, string? extraName, GenApiValue extraValue, CancellationToken ct)
- => formula.EvaluateAsync(name => ResolveAsync(name, extraName, extraValue, 0, ct), ct);
+ => formula.EvaluateAsync(name => ResolveAsync(name, extraName, extraValue, 0, ct), _mode, ct);
private async ValueTask ResolveAsync(string name, string? extraName, GenApiValue extraValue, int depth, CancellationToken ct)
{
@@ -84,7 +92,7 @@ private async ValueTask ResolveAsync(string name, string? extraName
{
if (depth >= MaxExpressionDepth)
throw new GenApiException($"Expression '{name}' of node '{_owner.Name}' nests too deeply.", _owner.Name);
- return await expression.EvaluateAsync(n => ResolveAsync(n, extraName, extraValue, depth + 1, ct), ct).ConfigureAwait(false);
+ return await expression.EvaluateAsync(n => ResolveAsync(n, extraName, extraValue, depth + 1, ct), _mode, ct).ConfigureAwait(false);
}
if (_variables.TryGetValue(name, out var variable)) return await ReadVariableAsync(variable, ct).ConfigureAwait(false);
throw new GenApiException($"Formula variable '{name}' is not defined in node '{_owner.Name}'.", _owner.Name);
@@ -203,12 +211,18 @@ private void Visit(string name, Dictionary state, List path
}
}
- /// 대상 한계값 한쪽을 FormulaFrom 으로 옮긴 값. 선언되지 않은 한계면 수식을 평가하지 않고 열린 끝(null)으로 둔다.
+ ///
+ /// 대상 한계값 한쪽을 FormulaFrom 으로 옮긴 값. 선언되지 않은 한계면 수식을 평가하지 않고 열린 끝(null)으로 둔다.
+ /// 옮긴 값이 실수 범위를 넘으면(대상의 큰 한계를 지수 변환에 넣은 경우 — 4 바이트 레지스터 최대를 dB 로 읽는 식) 그것도 열린 끝이다:
+ /// 무한대를 한계값으로 내놓으면 "한계 없음" 을 뜻하는 값이 둘이 되고, 예외로 막으면 그 노드의 쓰기가 통째로 막힌다.
+ ///
private async ValueTask EndpointAsync(NodeBase target, LimitKind kind, Formula formulaFrom, CancellationToken ct)
{
var limit = await target.ReadLimitAsync(kind, ct).ConfigureAwait(false);
if (IsOpenEnd(limit, kind)) return null;
- return await EvaluateAsync(formulaFrom, "TO", limit, ct).ConfigureAwait(false);
+ var mapped = await EvaluateAsync(formulaFrom, "TO", limit, ct).ConfigureAwait(false);
+ if (mapped.IsDouble && (double.IsInfinity(mapped.AsDouble) || double.IsNaN(mapped.AsDouble))) return null;
+ return mapped;
}
/// 한계값이 "선언 안 됨" 을 뜻하는 극단인지 — 정수 노드는 long 의 양끝, 실수 노드는 double 의 양끝(무한대 포함).
diff --git a/src/GevSharp/GenApi/Runtime/IntegerNodes.cs b/src/GevSharp/GenApi/Runtime/IntegerNodes.cs
index 9f5ddc9..4ab32a7 100644
--- a/src/GevSharp/GenApi/Runtime/IntegerNodes.cs
+++ b/src/GevSharp/GenApi/Runtime/IntegerNodes.cs
@@ -44,7 +44,15 @@ public async ValueTask SetAsync(long value, CancellationToken ct = default)
internal async ValueTask WriteInt64Async(long value, CancellationToken ct)
{
await ValidateAsync(value, ct).ConfigureAwait(false);
- await WriteCoreAsync(value, ct).ConfigureAwait(false);
+ try
+ {
+ await WriteCoreAsync(value, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
@@ -445,7 +453,7 @@ public IntSwissKnifeNode(IntSwissKnifeDef def, GenApiNodeMap map) : base(def, ma
protected override void BindCore(NodeBinder binder)
{
- _scope = new FormulaScope(this, _def, binder);
+ _scope = new FormulaScope(this, _def, binder, FormulaMode.Integer);
_formula = _scope.Parse(_def.Formula, "Formula");
}
@@ -483,7 +491,7 @@ protected override void BindCore(NodeBinder binder)
{
if (_def.PValue is null) throw new GenApiException($"IntConverter '{Name}' has no pValue.", Name);
_pValue = binder.Resolve(_def.PValue, RefKind.Value, "pValue", NodeBinder.Numeric);
- _scope = new FormulaScope(this, _def, binder);
+ _scope = new FormulaScope(this, _def, binder, FormulaMode.Integer);
_to = _scope.Parse(_def.FormulaTo, "FormulaTo");
_from = _scope.Parse(_def.FormulaFrom, "FormulaFrom");
}
diff --git a/src/GevSharp/GenApi/Runtime/OtherNodes.cs b/src/GevSharp/GenApi/Runtime/OtherNodes.cs
index 7f68fe1..61d2465 100644
--- a/src/GevSharp/GenApi/Runtime/OtherNodes.cs
+++ b/src/GevSharp/GenApi/Runtime/OtherNodes.cs
@@ -56,7 +56,15 @@ internal override async ValueTask WriteStringAsync(string value, CancellationTok
throw new GenApiException($"String of {bytes.Length} bytes does not fit node '{Name}' (max {len} bytes).", Name);
var buf = new byte[len];
Buffer.BlockCopy(bytes, 0, buf, 0, bytes.Length);
- await _core.WriteAsync(buf, ct).ConfigureAwait(false);
+ try
+ {
+ await _core.WriteAsync(buf, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
}
@@ -103,8 +111,16 @@ internal override ValueTask ReadStringAsync(CancellationToken ct)
internal override async ValueTask WriteStringAsync(string value, CancellationToken ct)
{
- if (_pValue is not null) await _pValue.WriteStringAsync(value, ct).ConfigureAwait(false);
- else Volatile.Write(ref _local, value);
+ try
+ {
+ if (_pValue is not null) await _pValue.WriteStringAsync(value, ct).ConfigureAwait(false);
+ else Volatile.Write(ref _local, value);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
}
@@ -154,10 +170,18 @@ internal async ValueTask ReadBoolAsync(CancellationToken ct)
internal async ValueTask WriteBoolAsync(bool value, CancellationToken ct)
{
- if (_pValue is not null)
- await _pValue.WriteValueAsync(new GenApiValue(value ? _def.OnValue : _def.OffValue), ct).ConfigureAwait(false);
- else
- Volatile.Write(ref _local, value ? 1 : 0);
+ try
+ {
+ if (_pValue is not null)
+ await _pValue.WriteValueAsync(new GenApiValue(value ? _def.OnValue : _def.OffValue), ct).ConfigureAwait(false);
+ else
+ Volatile.Write(ref _local, value ? 1 : 0);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
@@ -340,8 +364,16 @@ internal async ValueTask ReadInt64Async(CancellationToken ct)
internal async ValueTask WriteInt64Async(long value, CancellationToken ct)
{
if (await FindByValueAsync(value, ct).ConfigureAwait(false) is null) throw NoEntryFor(value);
- if (_pValue is not null) await _pValue.WriteValueAsync(new GenApiValue(value), ct).ConfigureAwait(false);
- else Interlocked.Exchange(ref _local, value);
+ try
+ {
+ if (_pValue is not null) await _pValue.WriteValueAsync(new GenApiValue(value), ct).ConfigureAwait(false);
+ else Interlocked.Exchange(ref _local, value);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
@@ -421,8 +453,16 @@ public async ValueTask ExecuteAsync(CancellationToken ct = default)
{
await EnsureWritableAsync(ct).ConfigureAwait(false);
var value = await CommandValueAsync(ct).ConfigureAwait(false);
- if (_pValue is not null) await _pValue.WriteValueAsync(new GenApiValue(value), ct).ConfigureAwait(false);
- else Interlocked.Exchange(ref _local, value);
+ try
+ {
+ if (_pValue is not null) await _pValue.WriteValueAsync(new GenApiValue(value), ct).ConfigureAwait(false);
+ else Interlocked.Exchange(ref _local, value);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
@@ -482,7 +522,15 @@ public async ValueTask GetAsync(Memory buffer, CancellationToken ct = defa
public async ValueTask SetAsync(ReadOnlyMemory data, CancellationToken ct = default)
{
await EnsureWritableAsync(ct).ConfigureAwait(false);
- await _core.WriteAsync(data.ToArray(), ct).ConfigureAwait(false);
+ try
+ {
+ await _core.WriteAsync(data.ToArray(), ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ Map.OnWriteFailed(this);
+ throw;
+ }
Map.OnWritten(this);
}
}
diff --git a/src/GevSharp/GenApi/Runtime/RegisterCore.cs b/src/GevSharp/GenApi/Runtime/RegisterCore.cs
index 6f3555f..3542437 100644
--- a/src/GevSharp/GenApi/Runtime/RegisterCore.cs
+++ b/src/GevSharp/GenApi/Runtime/RegisterCore.cs
@@ -231,7 +231,16 @@ public async ValueTask ReadForModifyAsync(CancellationToken ct)
return buf;
}
- /// 레지스터에 쓴다. 길이는 레지스터 길이와 같아야 한다. 캐시 정책을 적용하고, 쓴 바이트를 노드맵에 알려 그림자에 남기고 겹치는 다른 노드의 캐시를 버리게 한다.
+ ///
+ /// 레지스터에 쓴다. 길이는 레지스터 길이와 같아야 한다. 캐시 정책을 적용하고, 쓴 바이트를 노드맵에 알려 그림자에 남기고 겹치는 다른 노드의 캐시를 버리게 한다.
+ ///
+ /// 포트 쓰기가 실패하면 장치가 새 값을 받았는지 알 수 없다 — 명령은 응답을 기다리기 전에 이미 나가므로, 응답 유실·시한 초과·대기 중 취소는
+ /// 장치가 값을 바꾼 채로 여기 온다. 그래서 어떤 예외든 이 레지스터와 주소가 겹치는 캐시를 버리고 다시 던진다(다음 읽기가 장치에 묻는다).
+ /// 예외 종류로 가르지 않는다 — 장치가 거절했거나 보내기 전에 실패했다면 버린 값이 옳았을 뿐이고, 비용은 다음 읽기 한 번이다.
+ /// 그림자는 건드리지 않는다: "모름" 을 적을 자리가 없고, 지우면 형제 필드의 비트가 0 이 되어 다음 쓰기가 확실히 틀린다.
+ /// 옛 그림자는 틀릴 수 있을 뿐이고, 호출자가 다시 쓰면 바로잡힌다.
+ ///
+ ///
public async ValueTask WriteAsync(byte[] data, CancellationToken ct)
{
ThrowIfChunkPort();
@@ -240,7 +249,16 @@ public async ValueTask WriteAsync(byte[] data, CancellationToken ct)
if (data.Length != len)
throw new GenApiException($"Register '{_owner.Name}' expects {len} bytes, got {data.Length}.", _owner.Name);
- await Port.WriteAsync(addr, data, ct).ConfigureAwait(false);
+ try
+ {
+ await Port.WriteAsync(addr, data, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ DropCache();
+ _owner.Map.OnRegisterWriteFailed(this, addr, data.Length);
+ throw;
+ }
lock (_lock)
{
if (IsCacheable && _set.Cachable == Cachable.WriteThrough)
diff --git a/src/GevSharp/Gvsp/GevFrame.cs b/src/GevSharp/Gvsp/GevFrame.cs
index 3664a38..6becd21 100644
--- a/src/GevSharp/Gvsp/GevFrame.cs
+++ b/src/GevSharp/Gvsp/GevFrame.cs
@@ -111,7 +111,11 @@ internal GevFrame(GevFramePool pool, FrameBuf buf, int version, in FrameMeta met
/// 안의 유효 바이트 수.
public int PayloadSize { get; }
- /// 모든 페이로드 패킷이 모였는지. false 는 가 켜졌을 때만 온다.
+ ///
+ /// 모든 페이로드가 모였는지 — 예상 패킷을 다 받았고, 리더가 크기를 알린 프레임이면 그 바이트 끝까지 실제로 채워졌다.
+ /// 장치가 블록을 중간에 끊어(낮은 id 의 트레일러) 패킷 수는 맞아도 바이트가 모자라면 거짓이다.
+ /// false 는 가 켜졌을 때만 온다(못 받은 자리는 0).
+ ///
public bool IsComplete { get; }
/// 이미지 뒤에 청크 데이터가 붙어 있는지(리더의 bit14 또는 extended chunk 타입). 청크는 해석하지 않고 바이트만 실어 준다.
diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs
index 96ab836..ceb5133 100644
--- a/src/GevSharp/Gvsp/GevStream.Receiver.cs
+++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs
@@ -49,6 +49,7 @@ public sealed partial class GevStream
private int _payloadSizeHint;
private bool _hasLoggedChunkOverflow;
private bool _hasLoggedShortLeader;
+ private bool _hasLoggedShortBlock;
private readonly FrameSlot?[] _active = new FrameSlot?[MaxInFlightFrames];
private readonly FrameSlot[] _freeSlots = new FrameSlot[MaxInFlightFrames];
@@ -81,6 +82,8 @@ private sealed class FrameSlot
public ushort SkipCode;
public bool HasLeader;
public bool HasTrailer;
+ /// 트레일러가 알린 줄 수(0 은 모름). 리더가 트레일러 뒤에 와도 가변 높이 축소를 할 수 있게 남겨 둔다.
+ public uint TrailerSizeY;
public FrameMeta Meta;
/// 이미지 바이트 수. −1 은 미정(청크가 붙는 프레임) — 트레일러가 패킷 수를 정한다.
public long ExpectedBytes;
@@ -144,6 +147,7 @@ public void Reset(ulong blockId, bool extendedIds, long now)
SkipCode = 0;
HasLeader = false;
HasTrailer = false;
+ TrailerSizeY = 0;
Meta = default;
ExpectedBytes = -1;
DataBytes = 0;
@@ -252,6 +256,7 @@ private void InitReceiver(int packetSize)
_maxPayloadBytes = Math.Min(_opt.MaxPayloadBytes, int.MaxValue - ScratchSlackBytes);
_hasLoggedPayloadCeiling = false;
_hasLoggedShortLeader = false;
+ _hasLoggedShortBlock = false;
_punchIntervalTicks = _opt.FirewallTraversal && _opt.FirewallTraversalIntervalMs > 0
? MsToTicks(_opt.FirewallTraversalIntervalMs)
: 0;
@@ -720,6 +725,9 @@ private bool ApplyLeader(FrameSlot slot, in GvspImageLeader leader, bool extende
if (slot.DataBytes == 0) slot.DataBytes = extendedIds ? _dataBytesExt : _dataBytesStd;
+ // 리더가 리센드로 트레일러 뒤에 왔으면 트레일러가 먼저 알린 실제 줄 수를 여기서 적용한다 — 버퍼는 리더의 최대 크기로 잡는다.
+ ApplyTrailerHeight(slot);
+
// 청크가 붙으면 이미지보다 커진다 — 힌트(PayloadSize 옵션)나 지금까지의 최대 크기 중 큰 쪽을 잡는다.
var needed = hasChunk
? Math.Max((int)imageBytes, Math.Max(_payloadSizeHint, _pool.BufferBytes))
@@ -945,14 +953,8 @@ private void OnTrailer(FrameSlot slot, in GvspPacketView view, long now)
var n = (int)id - 1;
slot.EnsureCapacity(n + 2);
- if (view.TryReadTrailer(out var trailer) && slot.HasLeader && slot.ExpectedBytes >= 0
- && trailer.SizeY > 0 && trailer.SizeY < (uint)slot.Meta.Height)
- {
- // 가변 높이: 실제 줄 수만큼만 유효하다.
- slot.Meta.Height = (int)trailer.SizeY;
- slot.ExpectedBytes = (long)slot.Meta.Stride * trailer.SizeY + slot.Meta.PaddingY;
- slot.Meta.PayloadSize = (int)slot.ExpectedBytes;
- }
+ if (view.TryReadTrailer(out var trailer)) slot.TrailerSizeY = trailer.SizeY;
+ ApplyTrailerHeight(slot);
if (slot.ExpectedPackets != n && GevLog.IsEnabled(GevLogLevel.Debug))
{
@@ -966,6 +968,20 @@ private void OnTrailer(FrameSlot slot, in GvspPacketView view, long now)
slot.IsScanNeeded = true;
}
+ ///
+ /// 가변 높이: 트레일러가 알린 줄 수가 리더보다 적으면 그 줄까지만 유효하다. 리더와 트레일러가 둘 다 있어야 하므로 어느 쪽이 나중에 오든
+ /// 나중 쪽에서 부른다. 크기는 픽셀 포맷 규칙으로 다시 구한다 — 줄 간격 × 줄 수로 구하면 줄이 바이트 경계에서 끝나지 않는 패킹
+ /// (Stride 0)에서 0 이 되고, 패딩 없는 묶음 포맷은 줄에서 끊기지 않으므로 줄 간격의 배수도 아니다.
+ ///
+ private static void ApplyTrailerHeight(FrameSlot slot)
+ {
+ var sizeY = slot.TrailerSizeY;
+ if (!slot.HasLeader || slot.ExpectedBytes < 0 || sizeY == 0 || sizeY >= (uint)slot.Meta.Height) return;
+ slot.Meta.Height = (int)sizeY;
+ slot.ExpectedBytes = Pfnc.PixelFormatInfo.ImageBytesLong(slot.Meta.PixelFormatCode, slot.Meta.Width, sizeY, slot.Meta.PaddingX, slot.Meta.PaddingY);
+ slot.Meta.PayloadSize = (int)slot.ExpectedBytes;
+ }
+
/// 올인 패킷: 리더 36 바이트, 이미지 바이트, 끝에 트레일러 8 바이트가 한 데이터그램에 들어 있다.
private void OnAllIn(FrameSlot slot, in GvspPacketView view, long now)
{
@@ -1275,6 +1291,14 @@ private void CheckCompletion(long now, FrameSlot? current)
CloseSlot(i);
continue;
}
+ // 트레일러가 약속한 패킷은 다 왔는데 리더가 알린 바이트에 못 미친다 — 장치가 블록을 중간에 끊었다. 더 올 것이 없으므로
+ // 기다리지 않고 불완전으로 닫는다(보존 시간까지 두면 뒤 프레임이 그만큼 막힌다).
+ if (IsCutShort(slot))
+ {
+ LogCutShort(slot);
+ CloseSlot(i);
+ continue;
+ }
// 리더만 온 가장 새 프레임은 기다린다 — 노출이 긴 촬영에서 리더가 먼저 오는 장치가 있다.
var isLoneLeader = isNewest && slot.HasLeader && slot.ReceivedPayloads == 0 && !slot.HasTrailer;
if (!isLoneLeader)
@@ -1305,8 +1329,44 @@ private void CheckCompletion(long now, FrameSlot? current)
}
}
+ ///
+ /// 완성 = 리더가 있고, 예상 패킷을 다 받았고, 리더가 크기를 알렸으면 그 바이트 끝까지 실제로 받았다. 패킷 수만 보면 안 된다 —
+ /// 패킷 수는 트레일러가 정하는데, 블록을 중간에 끊은 장치의 트레일러는 적은 수를 알리므로 안 온 꼬리(풀에서 다시 쓰는 버퍼라
+ /// 이전 프레임의 픽셀이 남아 있다)가 완성으로 나간다. 마지막 패킷은 짧을 수 있어 패킷 수 × 패킷 크기가 아니라 받은 끝으로 본다.
+ ///
+ /// 이 검사는 리더에서 계산한 크기가 장치가 실제로 보내는 크기보다 크면 모든 프레임을 불완전으로 만든다. 알려진 자리가 하나 있다 —
+ /// GVSP Packed 에서 픽셀 수가 홀수면 우리 규칙이 묶음으로 올려 장치의 PayloadSize 보다 1 바이트 크다(Mono12Packed 2591×1943 실측:
+ /// 장치 7,551,470, 계산 7,551,471). 그 장치는 마지막 패킷을 그보다 길게 채워 보내므로 복사가 한계에서 잘려 완성된다(11/11).
+ /// 마지막 패킷을 채우지 않는 장치라면 여기서 걸린다 — 그때는 의 경고가 두 크기를 함께 적는다.
+ ///
+ ///
private static bool IsComplete(FrameSlot slot)
- => slot.HasLeader && slot.Buf is not null && slot.ExpectedPackets > 0 && slot.ReceivedPayloads >= slot.ExpectedPackets;
+ => slot.HasLeader && slot.Buf is not null && slot.ExpectedPackets > 0 && slot.ReceivedPayloads >= slot.ExpectedPackets
+ && (slot.ExpectedBytes < 0 || slot.ReceivedEnd >= slot.ExpectedBytes);
+
+ /// 트레일러가 약속한 패킷은 다 받았는데 리더가 알린 바이트에 못 미친다 — 장치가 블록을 끊었고 더 올 것이 없다.
+ private static bool IsCutShort(FrameSlot slot)
+ => slot.HasLeader && slot.HasTrailer && slot.Buf is not null && slot.ExpectedPackets > 0 && slot.ReceivedPayloads >= slot.ExpectedPackets
+ && slot.ExpectedBytes >= 0 && slot.ReceivedEnd < slot.ExpectedBytes;
+
+ ///
+ /// 끊긴 블록은 스트림당 한 번만 경고한다 — 획득을 멈출 때마다 끊는 장치라면 단발 그랩마다 한 줄씩 쌓인다. 그 뒤로는 불완전 프레임
+ /// 통계· 로 세고, 프레임마다의 자세한 줄은 Debug 로 남긴다.
+ ///
+ private void LogCutShort(FrameSlot slot)
+ {
+ if (!_hasLoggedShortBlock)
+ {
+ _hasLoggedShortBlock = true;
+ GevLog.Warn(_logSrc, $"Block {slot.BlockId}: the trailer ended the block after {slot.ExpectedPackets} payload packet(s) ({slot.ReceivedEnd} bytes) "
+ + $"but the leader announced {slot.ExpectedBytes} bytes; the frame is closed as incomplete. Further occurrences are counted as incomplete frames but not logged. "
+ + "If every frame ends this way, the device sends fewer bytes than its leader geometry implies (a size mismatch for this pixel format, not a cut block).");
+ }
+ else if (GevLog.IsEnabled(GevLogLevel.Debug))
+ {
+ GevLog.Debug(_logSrc, $"Block {slot.BlockId}: cut short at {slot.ReceivedEnd} of {slot.ExpectedBytes} bytes ({slot.ExpectedPackets} payload packets).");
+ }
+ }
private void CloseSlot(int index)
{
@@ -1347,6 +1407,12 @@ private void FinishSlot(FrameSlot slot)
}
var expected = slot.ExpectedPackets > 0 ? slot.ExpectedPackets : (int)slot.HighestPacketId;
+ // 트레일러가 리더가 알린 바이트보다 적은 패킷 수로 블록을 닫았으면, 모자란 바이트를 실었어야 할 패킷도 못 받은 패킷이다.
+ if (slot.HasLeader && slot.ExpectedBytes >= 0 && slot.DataBytes > 0)
+ {
+ var needed = (slot.ExpectedBytes + slot.DataBytes - 1) / slot.DataBytes;
+ if (needed > expected && needed < MaxPacketsPerFrame) expected = (int)needed;
+ }
var missing = Math.Max(0, expected - slot.ReceivedPayloads);
_stats.IncFramesIncomplete();
_stats.AddPacketsMissing(missing);
@@ -1380,11 +1446,15 @@ private static void FinalizePayloadSize(FrameSlot slot)
if (slot.ExpectedBytes < 0) slot.Meta.PayloadSize = (int)Math.Min(slot.ReceivedEnd, slot.Buf!.Data.Length);
}
- /// 불완전 프레임을 내보내기 전에 못 받은 패킷 자리를 0 으로 비운다 — 이전 프레임의 픽셀이 새어 보이지 않게.
+ ///
+ /// 불완전 프레임을 내보내기 전에 못 받은 패킷 자리를 0 으로 비운다 — 이전 프레임의 픽셀이 새어 보이지 않게.
+ /// 트레일러가 약속한 패킷 뒤로도 리더가 알린 크기가 남아 있으면(끊긴 블록) 그 꼬리도 비운다. 꼬리는 실제로 받은 끝부터 비운다 —
+ /// 끊긴 블록의 마지막 패킷이 짧으면 그 패킷 자리의 나머지도 장치가 쓰지 않은 바이트다.
+ ///
private static void ZeroHoles(FrameSlot slot)
{
var data = slot.Buf!.Data;
- var limit = slot.ExpectedBytes >= 0 ? slot.ExpectedBytes : slot.ReceivedEnd;
+ var limit = slot.ExpectedBytes >= 0 ? Math.Min(slot.ExpectedBytes, data.Length) : slot.ReceivedEnd;
for (uint id = 1; id <= (uint)slot.ExpectedPackets; id++)
{
if (slot.IsReceived(id)) continue;
@@ -1393,6 +1463,8 @@ private static void ZeroHoles(FrameSlot slot)
var length = (int)Math.Min(slot.DataBytes, limit - offset);
Array.Clear(data, (int)offset, length);
}
+ var tailStart = Math.Min((long)slot.ExpectedPackets * slot.DataBytes, slot.ReceivedEnd);
+ if (tailStart < limit) Array.Clear(data, (int)tailStart, (int)(limit - tailStart));
}
private void Enqueue(FrameSlot slot)
diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs
index dbb0b11..c18fb7a 100644
--- a/src/GevSharp/Gvsp/GevStream.cs
+++ b/src/GevSharp/Gvsp/GevStream.cs
@@ -130,8 +130,10 @@ public async Task StartAsync(CancellationToken ct = default)
// 장치가 테스트 패킷을 보낼 목적지를 먼저 알려 준다.
await WriteRegAsync(GvbsAddr.ScdaOffset, ToUInt32(_localAddress), ct).ConfigureAwait(false);
- await WriteRegAsync(GvbsAddr.ScpOffset, (uint)LocalPort, ct).ConfigureAwait(false);
+ // 표시는 보내기 **전에** 한다 — 명령은 응답을 기다리기 전에 나가므로, 응답 유실·시한 초과·대기 중 취소로 이 쓰기가
+ // 실패해도 장치는 포트를 받았을 수 있다. 안 받았다면 아래 되돌리기가 0 을 한 번 더 쓸 뿐이다.
hasWrittenScp = true;
+ await WriteRegAsync(GvbsAddr.ScpOffset, (uint)LocalPort, ct).ConfigureAwait(false);
await PunchFirewallAsync(socket, ct).ConfigureAwait(false);
@@ -263,6 +265,12 @@ public async Task StopAsync(CancellationToken ct = default)
/// 다음 프레임을 기다린다. 시작 전이거나 정지된 스트림이면 .
/// 받은 프레임은 반드시 Dispose 한다.
///
+ /// 장치가 사라져도 이 대기는 스스로 끝나지 않는다. 장치는 자기가 연 스트림을 모르므로 제어 상실
+ /// ( )이나 장치 Dispose 가 스트림을 멈추지 않고, 말없는 장치를 향한 수신은 계속 기다린다.
+ /// 토큰을 주거나, 제어 상실 처리에서(그리고 장치를 닫기 전에) 를 불러 푼다 — 그러면
+ /// 으로 끝난다.
+ ///
+ ///
/// 돌려주는 것은 큐의 머리이지 방금 찍힌 장이 아니다. 완성된 프레임은 받아 갈 때까지 큐에 남으므로,
/// 받는 쪽이 잠시 쉬었다면 다시 부르는 순간 그 사이에 쌓인 것부터 나온다. 장치 쪽 취득만 멈춘 경우도
/// 마찬가지다 — 취득을 멈춰도 이 스트림은 살아 있고 큐도 그대로다. 는 큐를 비우지만
diff --git a/tests/GevSharp.Tests/GenApi/Formula/FormulaTests.cs b/tests/GevSharp.Tests/GenApi/Formula/FormulaTests.cs
index 6f8af18..996eaea 100644
--- a/tests/GevSharp.Tests/GenApi/Formula/FormulaTests.cs
+++ b/tests/GevSharp.Tests/GenApi/Formula/FormulaTests.cs
@@ -15,6 +15,14 @@ private static GenApiValue Eval(string text, params (string Name, GenApiValue Va
return Formula.Parse(text).Evaluate(dict);
}
+ /// 실수 노드(SwissKnife·Converter)가 쓰는 실수 규칙으로 평가한다.
+ private static GenApiValue EvalReal(string text, params (string Name, GenApiValue Value)[] vars)
+ {
+ var dict = new Dictionary(StringComparer.Ordinal);
+ foreach (var (name, value) in vars) dict[name] = value;
+ return Formula.Parse(text).Evaluate(dict, FormulaMode.Real);
+ }
+
// ---- 정수 결과: 연산자·우선순위·결합·리터럴·함수 ----
[Theory]
@@ -491,6 +499,123 @@ public void GainConverterRoundTripsThroughFormulaToAndFormulaFrom()
Assert.Contains("position 13", ex.Message);
}
+ [Fact]
+ public void GainConverterFormulaFromWithIntegerRegister()
+ {
+ // 위 왕복의 TO 는 실수 120.0 이었다 — 실제로는 정수 레지스터 값이 온다. 정수 규칙이면 120/200 = 0 → 1.
+ Assert.Equal(1L, Eval("(10 ** (TO / 200))", ("TO", 120L)).AsInt64);
+ var back = EvalReal("(10 ** (TO / 200))", ("TO", 120L));
+ Assert.Equal(Math.Pow(10, 0.6), back.AsDouble, 1e-12);
+ }
+
+ // ---- 실수 규칙(실수 노드의 수식) ----
+
+ [Theory]
+ [InlineData("7 / 2", 3.5)]
+ [InlineData("-7 / 2", -3.5)]
+ [InlineData("1 / 3 * 3", 1.0)]
+ [InlineData("10 ** ((60 / 10) / 20)", 1.9952623149688795)]
+ [InlineData("2 ** -1", 0.5)]
+ public void RealModeDividesIntegersAsReals(string text, double expected)
+ {
+ var v = EvalReal(text);
+ Assert.True(v.IsDouble || v.AsDouble == expected, $"{text} = {v}");
+ Assert.Equal(expected, v.AsDouble, 15);
+ }
+
+ [Fact]
+ public void RealModeKeepsExactIntegerArithmeticUntilItOverflows()
+ {
+ // 정수끼리의 + - * ** 는 정확하므로 정수로 남는다 — 비교·비트 연산이 뒤따라도 값이 흐트러지지 않는다
+ var exact = EvalReal("A * B + 1", ("A", 3L), ("B", 4L));
+ Assert.True(exact.IsInteger);
+ Assert.Equal(13L, exact.AsInt64);
+
+ // 넘치면 예외 대신 실수로 계속한다. 대조군: 정수 규칙은 같은 식에서 넘침 예외.
+ Assert.Equal(Math.Pow(2, 70), EvalReal("2 ** 70").AsDouble);
+ Assert.Equal(4294967296.0 * 4294967296.0, EvalReal("A * A", ("A", 4294967296L)).AsDouble);
+ Assert.Equal(9223372036854775807.0 + 1.0, EvalReal("A + 1", ("A", long.MaxValue)).AsDouble);
+ Assert.Equal(9223372036854775808.0, EvalReal("-A", ("A", long.MinValue)).AsDouble);
+ Assert.Equal(9223372036854775808.0, EvalReal("ABS(A)", ("A", long.MinValue)).AsDouble);
+ Assert.Contains("overflow", Assert.Throws(() => Eval("2 ** 70")).Message);
+ Assert.Contains("overflow", Assert.Throws(() => Eval("A * A", ("A", 4294967296L))).Message);
+ }
+
+ [Theory]
+ [InlineData("(N / 2) & 1", 6L, 1L)] // 3.0 → 3
+ [InlineData("(N / 2) & 1", 5L, 0L)] // 2.5 → 0 방향 절삭 2 — 정수 나눗셈 뒤 비트 연산과 같은 결과
+ [InlineData("(N / 2) | 0", 7L, 3L)]
+ [InlineData("(N / 4) << 1", 10L, 4L)] // 2.5 → 2 → 4
+ [InlineData("1 << (N / 2)", 9L, 16L)] // 시프트 수도 같은 규칙: 4.5 → 4
+ [InlineData("~(N / 2)", 4L, -3L)]
+ [InlineData("(N / 2) & 1", -7L, 1L)] // 음수도 같다: -3.5 → -3
+ public void RealModeBitOperatorsTruncateRealOperands(string text, long n, long expected)
+ {
+ var v = EvalReal(text, ("N", n));
+ Assert.True(v.IsInteger);
+ Assert.Equal(expected, v.AsInt64);
+ // 대조군: 정수 규칙은 나눗셈이 이미 정수라 같은 답이 나온다(두 규칙 모두 0 방향 절삭이라 부호와 무관 — 2^53 이하에서)
+ Assert.Equal(expected, Eval(text, ("N", n)).AsInt64);
+ }
+
+ [Fact]
+ public void RealModeBitOperatorRejectsValuesThatCannotBeIntegers()
+ {
+ var nan = Assert.Throws(() => EvalReal("A & 1", ("A", double.NaN)));
+ Assert.Contains("NaN", nan.Message);
+ var big = Assert.Throws(() => EvalReal("A | 0", ("A", 1e30)));
+ Assert.Contains("64-bit", big.Message);
+ // 정수 규칙은 실수 피연산자를 비트 연산에 받지 않는다(그대로)
+ Assert.Contains("requires integer", Assert.Throws(() => Eval("A & 1", ("A", 2.0))).Message);
+ }
+
+ [Theory]
+ [InlineData("(N / 2) % 2", 7L, 1L)] // 3.5 → 3, 3 % 2 = 1 — 정수 나눗셈 뒤 나머지와 같다
+ [InlineData("(N / 2) % 2", -7L, -1L)]
+ [InlineData("N % (7 / 2)", 10L, 1L)] // 제수도 같은 규칙: 3.5 → 3
+ public void RealModeRemainderTruncatesRealOperandsLikeBitOperators(string text, long n, long expected)
+ {
+ var v = EvalReal(text, ("N", n));
+ Assert.True(v.IsInteger, $"{text} = {v}");
+ Assert.Equal(expected, v.AsInt64);
+ Assert.Equal(expected, Eval(text, ("N", n)).AsInt64); // 대조군: 정수 규칙과 같은 답
+ }
+
+ [Fact]
+ public void IntegerModeRemainderOfRealsStaysFmod()
+ {
+ // 공개 규칙(정수 규칙)의 실수 나머지는 그대로다
+ Assert.Equal(1.5, Eval("7.5 % 2").AsDouble);
+ Assert.Contains("NaN", Assert.Throws(() => EvalReal("A % 2", ("A", double.NaN))).Message);
+ }
+
+ [Theory]
+ [InlineData("B ** (X / 2)", 0L, -2L, "zero")] // 0 ** -1.0 — 지수가 나눗셈에서 실수로 와도 0 나눗셈이다
+ [InlineData("B ** (1 / X)", -8L, 3L, "undefined")] // (-8) ** (1/3) — 음수의 분수 거듭제곱은 실수가 아니다
+ public void RealModePowerRejectsZeroBaseNegativeExponentAndNaN(string text, long b, long x, string expected)
+ {
+ // (변수 이름에 E 를 쓰지 않는다 — E 는 상수 e 다)
+ var ex = Assert.Throws(() => EvalReal(text, ("B", b), ("X", x)));
+ Assert.Contains(expected, ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PowerOfRealOperandsNeverReturnsNaNOrDivisionByZero()
+ {
+ // 정수 규칙에서도 실수 피연산자의 거듭제곱은 무한대·NaN 을 값으로 흘리지 않는다
+ Assert.Contains("zero", Assert.Throws(() => Eval("0.0 ** -1")).Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("undefined", Assert.Throws(() => Eval("(-4.0) ** 0.5")).Message);
+ Assert.Equal(0.25, Eval("0.5 ** 2").AsDouble); // 대조군: 정상 값은 그대로
+ Assert.Equal(1.0, Eval("0.0 ** 0").AsDouble);
+ }
+
+ [Fact]
+ public void RealModeStillRejectsDivisionByZero()
+ {
+ Assert.Contains("zero", Assert.Throws(() => EvalReal("1 / A", ("A", 0L))).Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("zero", Assert.Throws(() => EvalReal("0 ** -1")).Message, StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public void ConverterFormulasPromoteAndTruncate()
{
diff --git a/tests/GevSharp.Tests/GenApi/Runtime/CacheInvalidationTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/CacheInvalidationTests.cs
index 0882e4a..8c8792b 100644
--- a/tests/GevSharp.Tests/GenApi/Runtime/CacheInvalidationTests.cs
+++ b/tests/GevSharp.Tests/GenApi/Runtime/CacheInvalidationTests.cs
@@ -349,4 +349,101 @@ public async Task ConcurrentReads_AreSafe()
await map.GetInteger("K").GetAsync();
Assert.Equal(settled, port.ReadCount); // 경합이 끝나면 캐시 하나로 수렴
}
+
+ // ---------------------------------------------------------------- 보낸 뒤 실패한 쓰기
+
+ /// 장치는 값을 받았는데 응답이 유실됐다 — 시한 초과(PENDING_ACK 뒤 포함)나 응답 대기 중 취소가 이 모양이다.
+ private static Exception LostReply(ulong address, byte[] data)
+ => new GevTimeoutException($"reply to the write at 0x{address:X} was lost");
+
+ [Fact]
+ public async Task WriteFailedAfterSend_NextReadAsksTheDevice()
+ {
+ var port = new MemoryPort();
+ port.U32(0x10, 5);
+ var r = Bind(IntReg("R", "0x10"), port).GetInteger("R");
+
+ Assert.Equal(5, await r.GetAsync());
+ await r.SetAsync(7);
+ Assert.Equal(7, await r.GetAsync());
+ Assert.Equal(1, port.ReadsAt(0x10)); // 대조군: 성공한 쓰기는 캐시에 남아 다시 묻지 않는다
+
+ port.FailAfterWrite = LostReply;
+ await Assert.ThrowsAsync(() => r.SetAsync(9).AsTask());
+ port.FailAfterWrite = null;
+
+ Assert.Equal(9, await r.GetAsync()); // 옛 캐시(7)가 아니라 장치가 든 값
+ Assert.Equal(2, port.ReadsAt(0x10));
+ }
+
+ [Fact]
+ public async Task WriteFailedAfterSend_DropsDependentCaches()
+ {
+ // 쓰기가 실패해도 장치가 받았을 수 있으면, 그 쓰기가 무효화했어야 할 캐시(pInvalidator 청취자)도 믿을 수 없다
+ var port = new MemoryPort();
+ port.U32(0x20, 1);
+ var body = IntReg("R", "0x10") + IntReg("Dep", "0x20", "R ");
+ var map = Bind(body, port);
+ var dep = map.GetInteger("Dep");
+
+ Assert.Equal(1, await dep.GetAsync());
+ await map.GetInteger("R").SetAsync(1);
+ Assert.Equal(1, await dep.GetAsync());
+ Assert.Equal(2, port.ReadsAt(0x20)); // 대조군: 성공한 쓰기는 청취자 캐시를 버린다
+
+ port.AfterWrite = (a, _) => { if (a == 0x10) port.U32(0x20, 2); }; // R 을 쓰면 장치가 Dep 을 바꾼다
+ port.FailAfterWrite = LostReply;
+ await Assert.ThrowsAsync(() => map.GetInteger("R").SetAsync(2).AsTask());
+ port.FailAfterWrite = null;
+
+ Assert.Equal(2, await dep.GetAsync());
+ Assert.Equal(3, port.ReadsAt(0x20));
+ }
+
+ [Fact]
+ public async Task WriteFailedAfterSend_LockPredicateAsksTheDeviceAgain()
+ {
+ // 획득 중이면 모드를 잠그는 흔한 모양: 시작/정지 명령이 같은 레지스터에 1/0 을 쓰고, 잠금 술어가 그 레지스터를 읽는다.
+ // 정지의 응답만 유실되면 캐시가 "획득 중" 으로 남아 장치에 묻지도 않고 모드 쓰기를 잠김으로 거절하던 경로다.
+ var port = new MemoryPort();
+ var body = IntReg("AcqEnabledReg", "0xA000")
+ + "AcqEnabledReg ACQEN = 1 "
+ + "AcqEnabledReg 1 "
+ + "AcqEnabledReg 0 "
+ + "IsAcquiring "
+ + "1 2 "
+ + "AcquisitionModeReg "
+ + IntReg("AcquisitionModeReg", "0xA028");
+ var map = Bind(body, port);
+ var mode = map.GetEnumeration("AcquisitionMode");
+
+ await map.GetCommand("AcquisitionStart").ExecuteAsync();
+ var locked = await Assert.ThrowsAsync(() => mode.SetAsync("Continuous").AsTask());
+ Assert.Contains("locked", locked.Message); // 대조군: 잠금 술어는 살아 있다
+
+ port.FailAfterWrite = LostReply;
+ await Assert.ThrowsAsync(() => map.GetCommand("AcquisitionStop").ExecuteAsync().AsTask());
+ port.FailAfterWrite = null;
+ Assert.Equal(0u, port.U32(0xA000)); // 장치는 멈췄다
+
+ await mode.SetAsync("Continuous"); // 캐시의 "획득 중" 이 아니라 장치에 다시 묻고 풀린다
+ Assert.Equal(1u, port.U32(0xA028));
+ }
+
+ [Fact]
+ public async Task WriteFailedAfterSend_WriteOnlyShadowKeepsSiblingBits()
+ {
+ // 쓰기 전용 레지스터의 그림자는 실패한 쓰기로 지우지 않는다 — 지우면 형제 필드의 비트가 0 이 되어 다음 쓰기가 확실히 틀린다
+ var port = new MemoryPort();
+ var body = MaskedIntReg("A", "0x2000", "31 16 ", access: "WO") + MaskedIntReg("B", "0x2000", "15 0 ", access: "WO");
+ var map = Bind(body, port);
+
+ await map.GetInteger("A").SetAsync(0x1234);
+ port.FailAfterWrite = LostReply;
+ await Assert.ThrowsAsync(() => map.GetInteger("B").SetAsync(0xABCD).AsTask());
+ port.FailAfterWrite = null;
+
+ await map.GetInteger("B").SetAsync(0xABCD); // 호출자가 다시 쓰면
+ Assert.Equal(0xABCD1234u, port.U32(0x2000)); // A 의 비트가 남아 있다
+ }
}
diff --git a/tests/GevSharp.Tests/GenApi/Runtime/FloatNodeTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/FloatNodeTests.cs
index ceeed33..6533faf 100644
--- a/tests/GevSharp.Tests/GenApi/Runtime/FloatNodeTests.cs
+++ b/tests/GevSharp.Tests/GenApi/Runtime/FloatNodeTests.cs
@@ -513,4 +513,101 @@ public async Task SwissKnife_UnknownVariable_ThrowsAtRead()
Assert.Equal("K", ex.NodeName);
Assert.Contains("NOPE", ex.Message);
}
+
+ // ---------------------------------------------------------------- 실수 노드의 수식은 실수로 나눈다
+ // 변수는 정수 레지스터에서 온다 — 실제 XML 에서 흔한 모양이고, 피연산자가 둘 다 정수여야 절삭이 드러난다.
+
+ [Fact]
+ public async Task SwissKnife_DividesIntegerRegistersAsReals()
+ {
+ // 프레임률 = 1e6 / 한 프레임의 시간(정수 레지스터). 대조군: 같은 식의 IntSwissKnife 는 정수로 자른다.
+ var port = new MemoryPort();
+ port.U32(0x10, 47619);
+ var body = "R 1000000 / N "
+ + "R 1000000 / N "
+ + IntReg("R", "0x10");
+ var map = Bind(body, port);
+
+ Assert.Equal(1_000_000.0 / 47619, await map.GetFloat("Rate").GetAsync());
+ Assert.Equal(21, await map.GetInteger("IntRate").GetAsync());
+ }
+
+ [Fact]
+ public async Task SwissKnife_NestedExpressionDividesAsReal()
+ {
+ var port = new MemoryPort();
+ port.U32(0x10, 5);
+ var body = "R N / 2 HALF + 0 "
+ + IntReg("R", "0x10");
+
+ Assert.Equal(2.5, await Bind(body, port).GetFloat("K").GetAsync());
+ }
+
+ [Fact]
+ public async Task SwissKnife_ShiftStaysIntegerWhileDivisionIsReal()
+ {
+ // 고정소수점 레지스터: 값 / 2^소수부비트. 시프트는 정수로, 그 뒤의 나눗셈은 실수로.
+ var port = new MemoryPort();
+ port.U32(0x10, 32768);
+ port.U32(0x14, 16);
+ var body = "R F RAW / (1 << FRAC) "
+ + IntReg("R", "0x10") + IntReg("F", "0x14");
+
+ Assert.Equal(0.5, await Bind(body, port).GetFloat("Gamma").GetAsync());
+ }
+
+ /// 0.1 dB 단위 정수 레지스터 ↔ 선형 이득. 한계값도 같은 변환을 거친다(pMax 사슬과 Converter 자신의 한계).
+ private const string GainBody =
+ "GainConv GainMaxConv "
+ + "ROUND(200 * LG(FROM)) 10 ** ((TO / 10) / 20) GainRaw Increasing "
+ + "ROUND(200 * LG(FROM)) 10 ** ((TO / 10) / 20) GainMaxRaw Increasing "
+ + "GainReg 0 240 "
+ + "0x10 4 RW Device BigEndian "
+ + "0x14 4 RO Device BigEndian ";
+
+ [Fact]
+ public async Task Converter_DecibelRegisterReadsAsReal()
+ {
+ var port = new MemoryPort();
+ port.U32(0x10, 60); // 6.0 dB
+ port.U32(0x14, 240);
+ var gain = Bind(GainBody, port).GetFloat("Gain");
+
+ Assert.Equal(Math.Pow(10, 0.3), await gain.GetAsync(), 12); // 절삭이면 (60/10)/20 = 0 → 1.0
+ Assert.Equal(Math.Pow(10, 1.2), await gain.GetMaxAsync(), 12);
+ }
+
+ [Fact]
+ public async Task Converter_EndpointThatOverflowsIsAnOpenEnd()
+ {
+ // 대상 한계가 크면(4 바이트 레지스터의 최대 4294967295) dB → 선형 변환이 실수 범위를 넘는다. 그 끝은 "한계 없음" 이다 —
+ // 무한대를 한계값으로 내놓지 않고, 예외로 쓰기 전체를 막지도 않는다.
+ var port = new MemoryPort();
+ var body = "ROUND(200 * LG(FROM)) 10 ** (TO / 200) Raw Increasing "
+ + "RawReg 0 4294967295 "
+ + IntReg("RawReg", "0x10");
+ var g = Bind(body, port).GetFloat("G");
+
+ Assert.Equal(1.0, await g.GetMinAsync());
+ Assert.Equal(double.MaxValue, await g.GetMaxAsync());
+ await g.SetAsync(5.0);
+ Assert.Equal(140u, port.U32(0x10)); // ROUND(200 * LG(5)) = 140
+ }
+
+ [Fact]
+ public async Task Converter_WriteWithinRealLimitsIsAcceptedAndReadsBack()
+ {
+ var port = new MemoryPort();
+ port.U32(0x14, 240);
+ var map = Bind(GainBody, port);
+ var gain = map.GetFloat("Gain");
+
+ await gain.SetAsync(12.0); // 한계 10^1.2 = 15.8 안 — 절삭된 한계(10)면 거절된다
+ Assert.Equal(216u, port.U32(0x10)); // ROUND(200 * LG(12)) = 216
+ Assert.Equal(Math.Pow(10, 1.08), await gain.GetAsync(), 12);
+
+ await map.GetFloat("GainConv").SetAsync(2.0); // Converter 자신의 한계(대상 Min/Max 를 FormulaFrom 으로)도 같은 규칙
+ Assert.Equal(60u, port.U32(0x10));
+ Assert.Equal(Math.Pow(10, 0.3), await gain.GetAsync(), 12);
+ }
}
diff --git a/tests/GevSharp.Tests/GenApi/Runtime/MemoryPort.cs b/tests/GevSharp.Tests/GenApi/Runtime/MemoryPort.cs
index adfbd2f..9e3d44e 100644
--- a/tests/GevSharp.Tests/GenApi/Runtime/MemoryPort.cs
+++ b/tests/GevSharp.Tests/GenApi/Runtime/MemoryPort.cs
@@ -27,6 +27,12 @@ public sealed record Access(ulong Address, byte[] Data);
/// 읽기 직전에 불린다 — 장치가 스스로 바꾸는 레지스터(폴링 대상)를 흉내 낼 때.
public Action? BeforeRead { get; set; }
+ ///
+ /// 쓰기를 메모리에 반영한 **뒤** 던질 예외 — 장치는 명령을 받아 값을 바꿨는데 응답이 유실된 경우(시한 초과·취소)를 흉내 낸다.
+ /// null 을 돌려주면 정상 완료다.
+ ///
+ public Func? FailAfterWrite { get; set; }
+
/// 주소 범위에 대한 읽기 횟수.
public int ReadsAt(ulong address)
{
@@ -86,6 +92,7 @@ public ValueTask WriteAsync(ulong address, ReadOnlyMemory data, Cancellati
Writes.Add(new Access(address, (byte[])copy.Clone()));
}
AfterWrite?.Invoke(address, copy);
+ if (FailAfterWrite?.Invoke(address, copy) is { } fault) throw fault;
return default;
}
diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs
index 959c96c..14f2ad4 100644
--- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs
+++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs
@@ -523,6 +523,23 @@ public async Task FailedRegisterWriteDuringStartResetsScpAndClosesSocket()
Assert.Throws(() => rig.Stream.TryReceive(out _));
}
+ [Fact]
+ public async Task ScpWriteFailingAfterSendIsStillReset()
+ {
+ // SCP 쓰기 자체의 응답이 유실됐다 — 장치는 포트를 받았을 수 있으므로 닫힌 포트로 쏘지 않게 되돌려야 한다
+ await using var rig = new StreamRig();
+ var scp = GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset);
+ rig.Regs.OnWrite = (addr, value) =>
+ {
+ if (addr == scp && value != 0) throw new GevTimeoutException("WRITEREG reply was lost");
+ };
+
+ await Assert.ThrowsAsync(() => rig.Stream.StartAsync(Ct));
+ Assert.False(rig.Stream.IsStarted);
+ var writes = rig.Regs.Writes;
+ Assert.Equal((scp, 0u), writes[writes.Length - 1]);
+ }
+
[Fact]
public async Task SlowSenderDoesNotTriggerSpuriousResends()
{
@@ -1202,6 +1219,147 @@ public async Task TrailerWithASmallerHeightShrinksTheFrame()
Assert.Equal(0, rig.Stream.Stats.ResendRequests);
}
+ ///
+ /// 풀 버퍼 하나를 정상 프레임으로 한 번 채워 둔 스트림 — 다음 프레임이 같은 버퍼를 받으므로, 덜 온 자리에 이전 프레임의
+ /// 바이트가 남아 있으면 눈에 보인다(새 버퍼는 0 이라 그 오염이 가려진다).
+ ///
+ private static async Task<(StreamRig Rig, GvspTestSender.SynthFrame Previous)> StartWithDirtyBufferAsync(bool deliverIncomplete)
+ {
+ var opt = StreamRig.DefaultOpt();
+ opt.BufferCount = 1;
+ opt.DeliverIncompleteFrames = deliverIncomplete;
+ var rig = new StreamRig(opt);
+ await rig.StartAsync();
+ var previous = rig.Sender.SendFrame(1, 64, 100, Mono8, seed: 0xAA);
+ using (var f = await rig.ReceiveAsync()) Assert.True(f.IsComplete);
+ return (rig, previous);
+ }
+
+ [Fact]
+ public async Task BlockCutShortByAnEarlyTrailerIsIncompleteNotStale()
+ {
+ // 장치가 블록을 중간에 끊고(정지 순간 등) 낮은 id 의 트레일러를 보냈다. 트레일러의 패킷 수만 믿으면 "다 받았다" 가 되어,
+ // 리더가 알린 바이트 중 안 온 꼬리에 이전 프레임의 픽셀이 남은 채 완성으로 나간다.
+ var (rig, previous) = await StartWithDirtyBufferAsync(deliverIncomplete: true);
+ await using (rig)
+ {
+ var cut = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 0x11);
+ Assert.Equal(5, cut.PacketCount);
+ var d = cut.DataBytesPerPacket;
+ rig.Sender.SendPacket(cut, 0, GvspConst.StatusSuccess);
+ rig.Sender.SendPacket(cut, 1, GvspConst.StatusSuccess);
+ rig.Sender.SendPacket(cut, 2, GvspConst.StatusSuccess);
+ rig.Sender.SendTrailer(cut, 3); // 높이는 리더 그대로 — 가변 높이 축소가 아니다
+
+ using var frame = await rig.ReceiveAsync();
+ Assert.Equal(2UL, frame.FrameId);
+ Assert.True(frame.Data.Span.Slice(0, 2 * d).SequenceEqual(cut.Data.AsSpan(0, 2 * d)));
+ var tail = frame.Data.Span.Slice(2 * d, cut.Data.Length - 2 * d);
+ // 검사기가 살아 있는지: 고치기 전에는 이 꼬리가 이전 프레임 바이트다
+ Assert.False(tail.SequenceEqual(previous.Data.AsSpan(2 * d, cut.Data.Length - 2 * d)), "tail still holds the previous frame");
+ Assert.True(tail.SequenceEqual(new byte[tail.Length]), "unreceived tail must be zeroed");
+ Assert.False(frame.IsComplete);
+ Assert.Equal(5, frame.ExpectedPackets);
+ Assert.Equal(3, frame.MissingPackets);
+
+ var diag = await rig.WaitDroppedAsync();
+ Assert.Equal(GevFrameDropReason.Incomplete, diag.Reason);
+ Assert.Equal(3, diag.MissingPackets);
+ Assert.Equal(1, rig.Stream.Stats.FramesCompleted);
+ Assert.Equal(1, rig.Stream.Stats.FramesIncomplete);
+ }
+ }
+
+ [Fact]
+ public async Task BlockCutInsideAPacketZeroesTheGapAfterTheShortLastPayload()
+ {
+ // 끊긴 블록의 마지막 페이로드가 짧다(패킷 가운데서 끊겼다). 그 패킷 자리의 나머지는 장치가 쓰지 않았으므로
+ // 트레일러가 약속한 패킷 수 × 패킷 크기가 아니라 실제로 받은 끝부터 비워야 이전 프레임 바이트가 남지 않는다.
+ var (rig, previous) = await StartWithDirtyBufferAsync(deliverIncomplete: true);
+ await using (rig)
+ {
+ var cut = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 0x11);
+ var d = cut.DataBytesPerPacket;
+ rig.Sender.SendPacket(cut, 0, GvspConst.StatusSuccess);
+ rig.Sender.SendPacket(cut, 1, GvspConst.StatusSuccess);
+ rig.Sender.SendPacket(cut, 2, GvspConst.StatusSuccess);
+ rig.Sender.SendPayloadWithArbitraryId(2, 3, 100); // 셋째 패킷은 100 바이트에서 끊겼다
+ rig.Sender.SendTrailer(cut, 4);
+
+ using var frame = await rig.ReceiveAsync();
+ Assert.False(frame.IsComplete);
+ var receivedEnd = 2 * d + 100;
+ var gap = frame.Data.Span.Slice(receivedEnd, 3 * d - receivedEnd);
+ Assert.False(gap.SequenceEqual(previous.Data.AsSpan(receivedEnd, gap.Length)), "gap still holds the previous frame");
+ Assert.True(frame.Data.Span.Slice(receivedEnd, cut.Data.Length - receivedEnd).SequenceEqual(new byte[cut.Data.Length - receivedEnd]));
+ }
+ }
+
+ [Fact]
+ public async Task BlockCutShortIsDroppedWhenIncompleteFramesAreNotDelivered()
+ {
+ // 기본 설정(불완전 프레임 안 받음)에서는 끊긴 블록이 나가지 않고, 뒤 프레임을 보존 시간만큼 막지도 않는다.
+ var (rig, _) = await StartWithDirtyBufferAsync(deliverIncomplete: false);
+ await using (rig)
+ {
+ var cut = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 0x11);
+ rig.Sender.SendPacket(cut, 0, GvspConst.StatusSuccess);
+ rig.Sender.SendPacket(cut, 1, GvspConst.StatusSuccess);
+ rig.Sender.SendTrailer(cut, 2);
+
+ var next = rig.Sender.SendFrame(3, 64, 100, Mono8, seed: 0x33);
+ using var frame = await rig.ReceiveAsync();
+ Assert.Equal(3UL, frame.FrameId);
+ Assert.True(frame.IsComplete);
+ Assert.True(frame.Data.Span.SequenceEqual(next.Data));
+ Assert.Equal(1, rig.Stream.Stats.FramesIncomplete);
+ }
+ }
+
+ [Fact]
+ public async Task LeaderRecoveredAfterAShorterTrailerStillShrinksTheFrame()
+ {
+ // 가변 높이 프레임의 리더가 유실돼 리센드로 트레일러 뒤에 왔다. 트레일러가 알린 실제 줄 수를 리더를 적용할 때도 써야
+ // 리더의 최대 줄 수로 크기를 잡아 덜 온 것처럼(또는 이전 픽셀이 남은 채) 닫지 않는다.
+ var (rig, _) = await StartWithDirtyBufferAsync(deliverIncomplete: false);
+ await using (rig)
+ {
+ var sent = rig.Sender.BuildFrame(2, 64, 50, Mono8, seed: 0x22);
+ sent.LeaderHeight = 100;
+ rig.Sender.Drop.Add((2, 0));
+ rig.Sender.SendFrame(sent);
+
+ using var frame = await rig.ReceiveAsync();
+ Assert.Equal(2UL, frame.FrameId);
+ Assert.True(frame.IsComplete);
+ Assert.Equal(50, frame.Height);
+ Assert.Equal(sent.Data.Length, frame.PayloadSize);
+ Assert.True(frame.Data.Span.SequenceEqual(sent.Data));
+ Assert.True(rig.Stream.Stats.ResendRequests > 0); // 리더는 정말 리센드로 왔다
+ }
+ }
+
+ [Fact]
+ public async Task VariableHeightOfABitPackedFormatKeepsItsPayloadSize()
+ {
+ // 줄이 바이트 경계에서 끝나지 않는 패킹(Mono12p 홀수 폭)은 줄 간격이 없어 Stride 가 0 이다 — 줄 간격 × 줄 수로
+ // 크기를 다시 계산하면 0 이 된다. 픽셀 포맷 규칙으로 실제 줄 수의 바이트를 구해야 한다.
+ const uint Mono12p = 0x010C0047;
+ await using var rig = new StreamRig();
+ await rig.StartAsync();
+
+ var sent = rig.Sender.BuildFrame(1, 63, 50, Mono12p, seed: 5);
+ sent.LeaderHeight = 100;
+ rig.Sender.SendFrame(sent);
+
+ using var frame = await rig.ReceiveAsync();
+ Assert.True(frame.IsComplete);
+ Assert.Equal(0, frame.Stride);
+ Assert.Equal(50, frame.Height);
+ Assert.Equal(sent.Data.Length, frame.PayloadSize);
+ Assert.True(frame.Data.Span.SequenceEqual(sent.Data));
+ }
+
[Fact]
public async Task PayloadLongerThanTheNegotiatedSizeSetsThePacketStride()
{
diff --git a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs
index 07614fb..b0de020 100644
--- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs
+++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs
@@ -407,4 +407,29 @@ public async Task GetXml_WorksFromAReadOnlySession()
Assert.Equal(0, sim.WriteRegCount);
Assert.Equal(0, sim.WriteMemCount);
}
+
+ // ---------------------------------------------------------------- stream vs. device lifetime
+
+ [Fact]
+ public async Task Stream_OutlivesItsDevice_WaitingReceiveEndsOnlyByTokenOrStop()
+ {
+ // 문서가 약속하는 계약을 못 박는다: 장치는 자기가 연 스트림을 모른다. 장치를 닫아도(제어 상실도 같은 길) 스트림은
+ // 시작된 채 남고, 프레임을 기다리는 ReceiveAsync 는 스스로 끝나지 않는다 — 토큰이나 StopAsync 만이 푼다.
+ await using var rig = await SimRig.StartAsync();
+ await using var stream = await rig.OpenStreamAsync();
+ await rig.Device.DisposeAsync();
+
+ Assert.False(rig.Device.IsOpen);
+ Assert.True(stream.IsStarted);
+ using (var cts = new CancellationTokenSource(300))
+ {
+ await Assert.ThrowsAnyAsync(() => stream.ReceiveAsync(cts.Token).AsTask());
+ }
+
+ var waiting = stream.ReceiveAsync().AsTask();
+ await Task.Delay(100);
+ Assert.False(waiting.IsCompleted);
+ await stream.StopAsync(); // 장치에 SCP = 0 을 못 써도(닫힘) 로컬 정리는 끝까지 간다
+ await Assert.ThrowsAsync(() => waiting);
+ }
}