mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-02 03:38:18 +00:00
527 lines
17 KiB
C++
527 lines
17 KiB
C++
/*
|
|
* SPDX-License-Identifier: MIT
|
|
* MeshTimeSync - mesh clock consensus from Ed25519-signed advert timestamps.
|
|
* See MeshTimeSync.h for the role contract, ARCHITECTURE.md for the design.
|
|
*/
|
|
|
|
#include "MeshTimeSync.h"
|
|
|
|
#include <adapters/clock/ZephyrRTCDiscover.h>
|
|
#include <helpers/time_sync.h>
|
|
#include <zephyr/kernel.h>
|
|
#include <zephyr/logging/log.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
LOG_MODULE_REGISTER(zephcore_timesync, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
|
|
|
|
/* Clamp an int64 skew to a printable long (display only). */
|
|
static long clampl(int64_t v)
|
|
{
|
|
if (v > 2000000000LL) return 2000000000L;
|
|
if (v < -2000000000LL) return -2000000000L;
|
|
return (long)v;
|
|
}
|
|
|
|
void MeshTimeSync::reset(uint32_t build_epoch, bool forward_only)
|
|
{
|
|
memset(_slots, 0, sizeof(_slots));
|
|
_build_epoch = build_epoch;
|
|
_forward_only = forward_only;
|
|
_next_eval_uptime = 0;
|
|
_suppress_uptime = 0;
|
|
_suppressed = false;
|
|
_pedigree_uptime = 0;
|
|
_pedigree = false;
|
|
_last_step_uptime = 0;
|
|
_stepped_once = false;
|
|
_evals = _abstains = _steps = _bootstrap_steps = _backward_skips = 0;
|
|
_last_step_delta = 0;
|
|
}
|
|
|
|
MeshTimeSync::Slot *MeshTimeSync::findSlot(const uint8_t *prefix)
|
|
{
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
if (_slots[i].used && memcmp(_slots[i].prefix, prefix, sizeof(_slots[i].prefix)) == 0) {
|
|
return &_slots[i];
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool MeshTimeSync::slotTenured(const Slot &s, uint32_t uptime_secs) const
|
|
{
|
|
return (uptime_secs - s.first_uptime) >= TENURE_SECS &&
|
|
s.count >= TENURE_MIN_ADVERTS;
|
|
}
|
|
|
|
bool MeshTimeSync::slotEligible(const Slot &s, uint32_t uptime_secs, bool bootstrap) const
|
|
{
|
|
return s.used && (uptime_secs - s.arrival_uptime) <= MAX_SAMPLE_AGE_SECS &&
|
|
(bootstrap || slotTenured(s, uptime_secs));
|
|
}
|
|
|
|
int64_t MeshTimeSync::slotSkew(const Slot &s, uint32_t local_time, uint32_t uptime_secs) const
|
|
{
|
|
/* Project the sender's clock forward by our elapsed uptime, then compare
|
|
* with our wall clock. Uptime anchor keeps this valid across own steps. */
|
|
return (int64_t)s.advert_ts + (int64_t)(uptime_secs - s.arrival_uptime) -
|
|
(int64_t)local_time;
|
|
}
|
|
|
|
bool MeshTimeSync::wouldAccept(const uint8_t *pubkey, uint32_t advert_ts) const
|
|
{
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
const Slot &s = _slots[i];
|
|
if (s.used && memcmp(s.prefix, pubkey, sizeof(s.prefix)) == 0) {
|
|
return advert_ts > s.advert_ts;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MeshTimeSync::onAdvertHeard(const uint8_t *pubkey, uint32_t advert_ts,
|
|
uint8_t hops, uint32_t uptime_secs)
|
|
{
|
|
if (hops > HOP_CAP) return;
|
|
|
|
Slot *s = findSlot(pubkey);
|
|
if (s) {
|
|
if (advert_ts <= s->advert_ts) return; /* per-sender monotonic dedup */
|
|
|
|
uint32_t d_up = uptime_secs - s->arrival_uptime;
|
|
uint32_t d_ts = advert_ts - s->advert_ts;
|
|
int64_t err = (int64_t)d_ts - (int64_t)d_up;
|
|
int64_t tol = CONSISTENCY_BASE_SECS + ((int64_t)d_up * CONSISTENCY_PPM) / 1000000;
|
|
if (err > tol || err < -tol) {
|
|
/* Sender rebooted, corrected itself, or is lying — re-earn
|
|
* tenure from scratch. >150 ppm is beyond crystal physics. */
|
|
s->first_uptime = uptime_secs;
|
|
s->count = 1;
|
|
} else if (s->count < 0xFFFF) {
|
|
s->count++;
|
|
}
|
|
s->advert_ts = advert_ts;
|
|
s->arrival_uptime = uptime_secs;
|
|
s->hops = hops;
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
if (!_slots[i].used) { s = &_slots[i]; break; }
|
|
}
|
|
|
|
if (!s) {
|
|
/* Hop-priority admission: a new sender may only displace a young
|
|
* (not tenure-eligible) entry farther than it; mature entries are
|
|
* protected unless silent > 24 h. Among candidates evict farthest,
|
|
* then lowest count, then stalest. (Naive LRU churns hub nodes to
|
|
* zero eligible voters.) */
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
Slot &c = _slots[i];
|
|
bool young = !slotTenured(c, uptime_secs);
|
|
bool silent = (uptime_secs - c.arrival_uptime) > MATURE_SILENT_EVICT_SECS;
|
|
if (!young && !silent) continue;
|
|
if (c.hops <= hops) continue;
|
|
if (s == nullptr ||
|
|
c.hops > s->hops ||
|
|
(c.hops == s->hops && c.count < s->count) ||
|
|
(c.hops == s->hops && c.count == s->count &&
|
|
c.arrival_uptime < s->arrival_uptime)) {
|
|
s = &c;
|
|
}
|
|
}
|
|
if (!s) return; /* table full of closer/protected senders — drop */
|
|
}
|
|
|
|
memcpy(s->prefix, pubkey, sizeof(s->prefix));
|
|
s->advert_ts = advert_ts;
|
|
s->arrival_uptime = uptime_secs;
|
|
s->first_uptime = uptime_secs;
|
|
s->count = 1;
|
|
s->hops = hops;
|
|
s->used = 1;
|
|
}
|
|
|
|
MeshTimeSync::Consensus MeshTimeSync::computeConsensus(uint32_t local_time,
|
|
uint32_t uptime_secs,
|
|
bool bootstrap) const
|
|
{
|
|
Consensus c;
|
|
memset(&c, 0, sizeof(c));
|
|
|
|
/* Collect eligible votes directly as Marzullo interval endpoints.
|
|
* Bootstrap relaxes tenure: any sender with a fresh sample may vote
|
|
* (the table is RAM-only, so after the reboot that dead-ended the
|
|
* clock everything in it is freshly heard anyway). */
|
|
int64_t val[2 * MESHTIMESYNC_TABLE_SIZE];
|
|
int8_t typ[2 * MESHTIMESYNC_TABLE_SIZE]; /* +1 = start, -1 = end */
|
|
int n = 0, m = 0;
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
const Slot &s = _slots[i];
|
|
if (!slotEligible(s, uptime_secs, bootstrap)) continue;
|
|
int64_t skew = slotSkew(s, local_time, uptime_secs);
|
|
int32_t r = RADIUS_BASE_SECS + RADIUS_PER_HOP_SECS * s.hops;
|
|
val[m] = skew - r; typ[m] = 1; m++;
|
|
val[m] = skew + r; typ[m] = -1; m++;
|
|
n++;
|
|
}
|
|
c.eligible = (uint8_t)n;
|
|
if (n == 0) return c;
|
|
|
|
/* Marzullo endpoint sweep — interval intersection with the most votes.
|
|
* No absolute outlier thresholds against our own clock: clustering does
|
|
* the rejection, so an epoch-0 local clock still finds the true cluster.
|
|
* Insertion sort by value; starts before ends at equal values so
|
|
* touching intervals count as overlapping. */
|
|
for (int i = 1; i < m; i++) {
|
|
int64_t v = val[i];
|
|
int8_t t = typ[i];
|
|
int j = i - 1;
|
|
while (j >= 0 && (val[j] > v || (val[j] == v && typ[j] < t))) {
|
|
val[j + 1] = val[j];
|
|
typ[j + 1] = typ[j];
|
|
j--;
|
|
}
|
|
val[j + 1] = v;
|
|
typ[j + 1] = t;
|
|
}
|
|
|
|
int cur = 0, best = 0, best_idx = 0;
|
|
for (int i = 0; i < m; i++) {
|
|
cur += typ[i];
|
|
if (typ[i] > 0 && cur > best) {
|
|
best = cur;
|
|
best_idx = i;
|
|
}
|
|
}
|
|
int64_t lo = val[best_idx];
|
|
int64_t hi = val[best_idx + 1]; /* next endpoint always exists and, at the
|
|
* maximum, is an end — a start would have
|
|
* raised the count past `best` */
|
|
|
|
c.valid = true;
|
|
c.votes_for = (uint8_t)best;
|
|
c.votes_against = (uint8_t)(n - best);
|
|
c.mid = (lo + hi) / 2;
|
|
c.radius = (int32_t)((hi - lo) / 2);
|
|
return c;
|
|
}
|
|
|
|
MeshTimeSync::Verdict MeshTimeSync::evaluateNow(uint32_t local_time,
|
|
uint32_t uptime_secs) const
|
|
{
|
|
Verdict v;
|
|
memset(&v, 0, sizeof(v));
|
|
|
|
bool boot = isBootstrap(local_time);
|
|
v.bootstrap = boot;
|
|
v.consensus = computeConsensus(local_time, uptime_secs, boot);
|
|
|
|
if (!v.consensus.valid) {
|
|
v.type = VERDICT_ABSTAIN;
|
|
v.reason = REASON_NO_DATA;
|
|
return v;
|
|
}
|
|
|
|
if (boot) {
|
|
if (v.consensus.votes_for < BOOTSTRAP_QUORUM) {
|
|
v.type = VERDICT_ABSTAIN;
|
|
v.reason = REASON_NO_QUORUM;
|
|
return v;
|
|
}
|
|
/* Step to the cluster's low edge: from below, all later refinement
|
|
* is forward steps, which are always monotonicity-safe. */
|
|
int64_t delta = v.consensus.mid - RADIUS_BASE_SECS;
|
|
if (delta < STEP_TRIGGER_SECS) {
|
|
v.type = VERDICT_NONE;
|
|
v.reason = REASON_IN_BAND;
|
|
return v;
|
|
}
|
|
if (isSuppressed(uptime_secs)) { /* suppression gates bootstrap too */
|
|
v.type = VERDICT_NONE;
|
|
v.reason = REASON_SUPPRESSED;
|
|
return v;
|
|
}
|
|
if (_stepped_once && (uptime_secs - _last_step_uptime) < STEP_INTERVAL_SECS) {
|
|
v.type = VERDICT_NONE;
|
|
v.reason = REASON_RATE_LIMITED;
|
|
return v;
|
|
}
|
|
v.type = VERDICT_STEP;
|
|
v.delta = delta; /* bootstrap exempt from the ±1 h cap */
|
|
return v;
|
|
}
|
|
|
|
if (v.consensus.eligible < MESHTIMESYNC_QUORUM) {
|
|
v.type = VERDICT_ABSTAIN;
|
|
v.reason = REASON_NO_QUORUM;
|
|
return v;
|
|
}
|
|
if ((int)v.consensus.votes_for * 2 <= (int)v.consensus.eligible) {
|
|
v.type = VERDICT_ABSTAIN;
|
|
v.reason = REASON_NO_MAJORITY;
|
|
return v;
|
|
}
|
|
|
|
int64_t mag = v.consensus.mid < 0 ? -v.consensus.mid : v.consensus.mid;
|
|
if (mag < STEP_TRIGGER_SECS) {
|
|
v.type = VERDICT_NONE;
|
|
v.reason = REASON_IN_BAND;
|
|
return v;
|
|
}
|
|
if (isSuppressed(uptime_secs)) {
|
|
v.type = VERDICT_NONE;
|
|
v.reason = REASON_SUPPRESSED;
|
|
return v;
|
|
}
|
|
if (_stepped_once && (uptime_secs - _last_step_uptime) < STEP_INTERVAL_SECS) {
|
|
v.type = VERDICT_NONE;
|
|
v.reason = REASON_RATE_LIMITED;
|
|
return v;
|
|
}
|
|
if (_pedigree) {
|
|
/* Physics veto: with a trusted sync + continuous uptime since, a
|
|
* real crystal cannot have drifted further than 300 ppm allows. */
|
|
int64_t envelope = ((int64_t)(uptime_secs - _pedigree_uptime) * PEDIGREE_PPM) / 1000000 +
|
|
PEDIGREE_BASE_SECS;
|
|
if (mag > envelope) {
|
|
v.type = VERDICT_ABSTAIN;
|
|
v.reason = REASON_PEDIGREE;
|
|
return v;
|
|
}
|
|
}
|
|
|
|
int64_t delta = v.consensus.mid;
|
|
if (delta > STEP_CAP_SECS) delta = STEP_CAP_SECS;
|
|
if (delta < -STEP_CAP_SECS) delta = -STEP_CAP_SECS;
|
|
v.type = VERDICT_STEP;
|
|
v.delta = delta;
|
|
return v;
|
|
}
|
|
|
|
MeshTimeSync::Verdict MeshTimeSync::tick(uint32_t local_time, uint32_t uptime_secs)
|
|
{
|
|
Verdict v;
|
|
if (_next_eval_uptime == 0 || uptime_secs < _next_eval_uptime) {
|
|
/* First call arms the pacing timer (skips a junk no-data abstain
|
|
* at boot); later calls no-op between evaluations. */
|
|
if (_next_eval_uptime == 0) {
|
|
_next_eval_uptime = uptime_secs + EVAL_INTERVAL_SECS;
|
|
}
|
|
memset(&v, 0, sizeof(v));
|
|
return v;
|
|
}
|
|
_next_eval_uptime = uptime_secs + EVAL_INTERVAL_SECS;
|
|
_evals++;
|
|
|
|
v = evaluateNow(local_time, uptime_secs);
|
|
if (v.type == VERDICT_ABSTAIN) _abstains++;
|
|
return v;
|
|
}
|
|
|
|
bool MeshTimeSync::runTick(mesh::RTCClock &rtc)
|
|
{
|
|
uint32_t up = (uint32_t)(k_uptime_get() / 1000);
|
|
uint32_t now = rtc.getCurrentTime();
|
|
Verdict v = tick(now, up);
|
|
if (v.type != VERDICT_STEP) {
|
|
if (v.type == VERDICT_ABSTAIN) {
|
|
LOG_DBG("abstain (%s)", reasonStr(v.reason));
|
|
}
|
|
return false;
|
|
}
|
|
if (_forward_only && v.delta < 0) {
|
|
/* Forward-only roles: post timestamps feed client sync_since
|
|
* ordering (room server) / peers hold per-sender replay high-water
|
|
* marks for our DMs (companion). Report, never apply. */
|
|
_backward_skips++;
|
|
LOG_WRN("backward step %+ld s wanted - skipped (forward-only role)",
|
|
clampl(v.delta));
|
|
return false;
|
|
}
|
|
int64_t nt = (int64_t)now + v.delta;
|
|
if (nt <= 0 || nt > (int64_t)UINT32_MAX) {
|
|
/* Would wrap the uint32 clock — only reachable with a garbage
|
|
* bootstrap cluster near the timestamp ceiling. */
|
|
LOG_WRN("implausible step %+ld s refused", clampl(v.delta));
|
|
return false;
|
|
}
|
|
uint32_t new_time = (uint32_t)nt;
|
|
rtc.setCurrentTime(new_time);
|
|
zephcore_rtc_save(new_time);
|
|
time_sync_report(TIME_SYNC_MESH);
|
|
noteStepApplied(v.delta, up, v.bootstrap);
|
|
LOG_WRN("stepped clock %+ld s (%s, votes %u/%u) -> %u",
|
|
clampl(v.delta), v.bootstrap ? "bootstrap" : "consensus",
|
|
(unsigned)v.consensus.votes_for, (unsigned)v.consensus.votes_against,
|
|
(unsigned)new_time);
|
|
return true;
|
|
}
|
|
|
|
void MeshTimeSync::noteManualSync(uint32_t uptime_secs)
|
|
{
|
|
_suppress_uptime = uptime_secs;
|
|
_suppressed = true;
|
|
_pedigree_uptime = uptime_secs;
|
|
_pedigree = true;
|
|
}
|
|
|
|
void MeshTimeSync::noteGPSSync(uint32_t uptime_secs)
|
|
{
|
|
/* A GPS fix owns the clock exactly like a manual set: arm the same 7-day
|
|
* suppression window (re-armed on every fix, so a repeater's 48 h GPS
|
|
* duty cycle keeps it owning the clock) plus the drift-envelope pedigree.
|
|
* A unit whose GPS goes dead becomes mesh-correctable once the window
|
|
* lapses without a new fix. */
|
|
_suppress_uptime = uptime_secs;
|
|
_suppressed = true;
|
|
_pedigree_uptime = uptime_secs;
|
|
_pedigree = true;
|
|
}
|
|
|
|
void MeshTimeSync::noteStepApplied(int64_t delta, uint32_t uptime_secs, bool bootstrap)
|
|
{
|
|
_last_step_uptime = uptime_secs;
|
|
_stepped_once = true;
|
|
_steps++;
|
|
if (bootstrap) _bootstrap_steps++;
|
|
_last_step_delta = delta;
|
|
}
|
|
|
|
bool MeshTimeSync::isSuppressed(uint32_t uptime_secs) const
|
|
{
|
|
return _suppressed && (uptime_secs - _suppress_uptime) < SUPPRESS_SECS;
|
|
}
|
|
|
|
uint32_t MeshTimeSync::suppressRemaining(uint32_t uptime_secs) const
|
|
{
|
|
if (!isSuppressed(uptime_secs)) return 0;
|
|
return SUPPRESS_SECS - (uptime_secs - _suppress_uptime);
|
|
}
|
|
|
|
const char *MeshTimeSync::reasonStr(Reason r)
|
|
{
|
|
switch (r) {
|
|
case REASON_IN_BAND: return "in-band";
|
|
case REASON_NO_DATA: return "no-data";
|
|
case REASON_NO_QUORUM: return "no-quorum";
|
|
case REASON_NO_MAJORITY: return "no-majority";
|
|
case REASON_SUPPRESSED: return "suppressed";
|
|
case REASON_RATE_LIMITED: return "rate-limited";
|
|
case REASON_PEDIGREE: return "pedigree-veto";
|
|
default: return "-";
|
|
}
|
|
}
|
|
|
|
int MeshTimeSync::formatStatus(char *out, size_t cap, uint32_t local_time,
|
|
uint32_t uptime_secs, bool enabled) const
|
|
{
|
|
Verdict v = evaluateNow(local_time, uptime_secs);
|
|
const Consensus &c = v.consensus;
|
|
|
|
char verdict[56];
|
|
if (v.type == VERDICT_STEP) {
|
|
/* Annotate steps the shared step policy would refuse, so the
|
|
* dry-run never claims a step that will not happen. (Suppression —
|
|
* manual or GPS — surfaces as a hold verdict inside evaluateNow,
|
|
* so it needs no annotation here.) */
|
|
const char *note = "";
|
|
if (_forward_only && v.delta < 0) {
|
|
note = " (skipped: forward-only)";
|
|
}
|
|
snprintf(verdict, sizeof(verdict), "step%+ld%s%s", clampl(v.delta),
|
|
v.bootstrap ? " (bootstrap)" : "", note);
|
|
} else if (v.type == VERDICT_NONE && v.reason == REASON_IN_BAND) {
|
|
int64_t mag = c.mid < 0 ? -c.mid : c.mid;
|
|
snprintf(verdict, sizeof(verdict), "%s",
|
|
mag <= DEAD_BAND_SECS ? "ok" : "in-band");
|
|
} else if (v.type == VERDICT_NONE) {
|
|
snprintf(verdict, sizeof(verdict), "hold (%s)", reasonStr(v.reason));
|
|
} else {
|
|
snprintf(verdict, sizeof(verdict), "abstain (%s)", reasonStr(v.reason));
|
|
}
|
|
|
|
int pos = snprintf(out, cap,
|
|
"%s%s eligible=%u votes=%u/%u skew=%+lds r=%lds -> %s",
|
|
enabled ? "on" : "off",
|
|
enabled ? "" : " (dry-run)",
|
|
(unsigned)c.eligible, (unsigned)c.votes_for,
|
|
(unsigned)c.votes_against,
|
|
c.valid ? clampl(c.mid) : 0L,
|
|
c.valid ? (long)c.radius : 0L,
|
|
verdict);
|
|
if (pos < 0 || (size_t)pos >= cap) goto full;
|
|
|
|
if (_steps > 0) {
|
|
pos += snprintf(out + pos, cap - pos, "; steps=%lu boot=%lu last=%+lds",
|
|
(unsigned long)_steps, (unsigned long)_bootstrap_steps,
|
|
clampl(_last_step_delta));
|
|
if (pos < 0 || (size_t)pos >= cap) goto full;
|
|
}
|
|
if (_backward_skips > 0) {
|
|
pos += snprintf(out + pos, cap - pos, " backskip=%lu",
|
|
(unsigned long)_backward_skips);
|
|
if (pos < 0 || (size_t)pos >= cap) goto full;
|
|
}
|
|
if (isSuppressed(uptime_secs)) {
|
|
pos += snprintf(out + pos, cap - pos, " sup=%luh",
|
|
(unsigned long)(suppressRemaining(uptime_secs) / 3600));
|
|
if (pos < 0 || (size_t)pos >= cap) goto full;
|
|
}
|
|
pos += snprintf(out + pos, cap - pos, " evals=%lu abst=%lu",
|
|
(unsigned long)_evals, (unsigned long)_abstains);
|
|
if (pos < 0 || (size_t)pos >= cap) goto full;
|
|
|
|
{
|
|
/* Evidence table: entries that actually count toward this verdict
|
|
* print first, then the rest — on a size-capped reply (161 B over
|
|
* remote admin), the buffer must not run out on still-building-tenure
|
|
* entries while hiding the ones that explain the verdict.
|
|
* (Braced so these locals go out of scope before the `full` label
|
|
* below — a goto may not jump over a variable's initialization.) */
|
|
int total_used = 0;
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
if (_slots[i].used) total_used++;
|
|
}
|
|
|
|
int printed = 0;
|
|
for (int pass = 0; pass < 2 && printed < total_used; pass++) {
|
|
for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) {
|
|
const Slot &s = _slots[i];
|
|
if (!s.used) continue;
|
|
bool participates = slotEligible(s, uptime_secs, v.bootstrap);
|
|
if (participates != (pass == 0)) continue;
|
|
|
|
int w = snprintf(out + pos, cap - pos, "\r\n %02x%02x h%u n%u %+lds%s",
|
|
s.prefix[0], s.prefix[1], (unsigned)s.hops,
|
|
(unsigned)(s.count > 99 ? 99 : s.count),
|
|
clampl(slotSkew(s, local_time, uptime_secs)),
|
|
participates ? " E" : "");
|
|
if (w < 0 || (size_t)(pos + w) >= cap) {
|
|
out[pos] = 0; /* drop the partial entry */
|
|
goto truncated;
|
|
}
|
|
pos += w;
|
|
printed++;
|
|
}
|
|
}
|
|
return pos;
|
|
|
|
truncated:
|
|
if (printed < total_used) {
|
|
char tail[24];
|
|
int tw = snprintf(tail, sizeof(tail), "\r\n +%d more", total_used - printed);
|
|
if (tw > 0 && (size_t)tw < cap - (size_t)pos) {
|
|
memcpy(out + pos, tail, (size_t)tw + 1);
|
|
pos += tw;
|
|
}
|
|
}
|
|
return pos;
|
|
}
|
|
|
|
full:
|
|
out[cap - 1] = 0;
|
|
return (int)(cap - 1);
|
|
}
|