Fill in the rest of N1MM's network status window
Seven tabs now, in N1MM's order, and the messages behind them. **Options** holds what there is something behind: Log QSOs at all Stations, which decides whether the contacts the others log are kept here; the message trace, moved off the Messages tab where N1MM does not keep it; and Run1/Run2, which used to be guessed from the radio number and is now the operator's choice, sent in every `STATUS` and every `XMIT`. The transmit interlocks stay out: this program does not block a transmitter, and a station that claimed it did would be waited on for nothing. **Call Stack Targets.** A callsign heard at one station and handed to the position that will work it — `STACKANOTHERCALL` with the call alone, `STACKCALL` with the contact behind it. The target is picked from the tab or from the station list, and what arrives goes on the call stack of the radio the operator is on, sorted by whether it is a multiplier here. **The master** is the station the entry follows for cluster spots and the time. `MASTER` names it to everybody, so the column agrees everywhere. **The clock.** `TIME` carries a station's clock and Force Time Sync sends it. N1MM sets the clock from it when it is running as an administrator and says how far out it is when it is not; this program always does the second, because setting the clock needs root and a contest logger has no business asking for it. Anything over two seconds is called out. **Serial numbers.** Reset Serial Numbers at all Stations sends `RESETQSONRS` and every station starts again at one. The contacts already logged keep the numbers they were sent with. **What the entry agrees on** is now checked rather than assumed: | What | How | |---|---| | the contest | `CONTESTNAME` as soon as a connection opens, and again in every `CHECKSUM` | | the country file | its issue date — the publisher's `=VER20260826` — rides in `STATUS` | | the computer name | longer than N1MM's fifteen characters is named at startup; a second computer answering to this one's name is named when its beacon arrives | | the station number | two stations on the same number are named when the second says so | `CountryFile` reads its issue date, which nothing had asked it for before. Left for later: the master relaying its cluster spots, and the shared serial-number pool where stations claim numbers in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -18,6 +18,14 @@ public interface ContactLog
|
||||
/// The callsign this station is logging under, for the same reason.
|
||||
string MyCall { get; }
|
||||
|
||||
/// The sub-type of the contest — the state of a QSO party, say. Empty for
|
||||
/// a contest that has none.
|
||||
string ContestSubType { get; }
|
||||
|
||||
/// Which issue of the country file this station is scoring with, or empty
|
||||
/// when it has no country file.
|
||||
string CountryFileVersion { get; }
|
||||
|
||||
/// The contacts one computer made inside one block of time, oldest first.
|
||||
IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length);
|
||||
|
||||
|
||||
@@ -42,8 +42,27 @@ public sealed class NetworkedStation
|
||||
|
||||
public string Operator { get; internal set; } = "";
|
||||
|
||||
/// Which contest the station said it is logging, and the sub-type of it —
|
||||
/// the state of a QSO party, say. Two stations on different contests must
|
||||
/// not resynchronise into each other's logs.
|
||||
public string ContestName { get; internal set; } = "";
|
||||
|
||||
public string ContestSubType { get; internal set; } = "";
|
||||
|
||||
/// Which issue of the country file the station is scoring with.
|
||||
public string CountryFileVersion { get; internal set; } = "";
|
||||
|
||||
/// True for the station the entry has named master: the one whose cluster
|
||||
/// spots the others take and whose clock the others follow.
|
||||
public bool IsMaster { get; internal set; }
|
||||
|
||||
/// How far the station's clock is ahead of this one's, from the last `TIME`
|
||||
/// it sent. Null until it has sent one.
|
||||
public TimeSpan? ClockOffset { get; internal set; }
|
||||
|
||||
/// True for the station this one hands stacked callsigns to.
|
||||
public bool IsCallStackTarget { get; internal set; }
|
||||
|
||||
/// Where the station is, from the last `FREQ` or `STATUS` it sent.
|
||||
public Frequency Frequency { get; internal set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// A callsign another station handed to this one's call stack: a station it
|
||||
/// heard and is not going to work itself.
|
||||
///
|
||||
/// `Contact` is the contact behind it when the other end had one — the
|
||||
/// frequency and whatever exchange it had already taken — and null when the
|
||||
/// operator sent nothing but the call.
|
||||
public sealed record StackedCall(string Call, Qso? Contact, string FromComputerName);
|
||||
@@ -41,6 +41,11 @@ public sealed class StationLink : IDisposable
|
||||
/// on a network carrying contest traffic.
|
||||
public static readonly TimeSpan BeaconInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// The longest a computer name may be. It is a NetBIOS name, which is
|
||||
/// fifteen characters, and N1MM asks for names inside that so every station
|
||||
/// is known by the same name everywhere.
|
||||
public const int LongestComputerName = 15;
|
||||
|
||||
/// How long a station may say nothing before the window shows it as gone.
|
||||
/// Three beacons.
|
||||
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
||||
@@ -58,6 +63,15 @@ public sealed class StationLink : IDisposable
|
||||
private UdpClient? beacons;
|
||||
private TcpListener? listener;
|
||||
private readonly List<Task> loops = [];
|
||||
/// The stations already told about a country file that is not this one's,
|
||||
/// so the message is not repeated with every status that arrives.
|
||||
private readonly HashSet<string> warnedAboutCountryFile = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// The addresses already named as answering to this computer's name.
|
||||
private readonly HashSet<string> warnedAboutName = [];
|
||||
/// The address this station's own beacon goes out with, so a beacon from
|
||||
/// somewhere else with this station's name can be told apart from the echo
|
||||
/// of our own.
|
||||
private string myAddress = "";
|
||||
|
||||
public StationLink(string computerName, string version, int stationNumber = 1, int port = StationBeacon.DefaultPort)
|
||||
{
|
||||
@@ -112,6 +126,15 @@ public sealed class StationLink : IDisposable
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
/// Another station asked every station to start its serial numbers again
|
||||
/// at one. The numbers come out of the log, which is not the link's, so the
|
||||
/// owner is told and does it.
|
||||
public event EventHandler? SerialNumbersReset;
|
||||
|
||||
/// A callsign another station handed to this one's call stack, with the
|
||||
/// contact behind it when there was one.
|
||||
public event EventHandler<StackedCall>? CallStacked;
|
||||
|
||||
/// Another station asked for a rescore when its resync is done. The log is
|
||||
/// not this program's to score, so the owner is told and does it.
|
||||
public event EventHandler? RescoreRequested;
|
||||
@@ -141,6 +164,12 @@ public sealed class StationLink : IDisposable
|
||||
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
|
||||
return;
|
||||
}
|
||||
if (computerName.Length > LongestComputerName)
|
||||
{
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"this computer is called {computerName}, which is longer than the {LongestComputerName} characters N1MM allows — the other stations may know it by another name");
|
||||
}
|
||||
loops.Add(Task.Run(() => BeaconLoopAsync(stopping.Token)));
|
||||
loops.Add(Task.Run(() => ListenForBeaconsAsync(stopping.Token)));
|
||||
loops.Add(Task.Run(() => AcceptAsync(stopping.Token)));
|
||||
@@ -367,6 +396,61 @@ public sealed class StationLink : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// This station's clock, so the others can see whether theirs agree.
|
||||
/// N1MM sets the clock from it when it is running as an administrator; this
|
||||
/// program says how far off it is and leaves the clock alone, which is what
|
||||
/// N1MM does the rest of the time.
|
||||
public Task<int> SendTimeAsync(CancellationToken cancellation = default) =>
|
||||
SendAsync(StationMessages.Time(DateTime.UtcNow, StationNumber, computerName), cancellation);
|
||||
|
||||
/// Names the station the whole entry follows for cluster spots and the
|
||||
/// time. Every station is told, this one included, so the window agrees
|
||||
/// everywhere.
|
||||
public Task<int> SendMasterAsync(NetworkedStation master, CancellationToken cancellation = default)
|
||||
{
|
||||
MarkMaster(master.ComputerName);
|
||||
return SendAsync(StationMessages.Master(master.ComputerName, StationNumber, computerName), cancellation);
|
||||
}
|
||||
|
||||
/// Marks the station stacked callsigns go to, or none with null. It is
|
||||
/// only this station's choice: N1MM keeps the target at the station doing
|
||||
/// the sending and tells nobody.
|
||||
public void SetCallStackTarget(NetworkedStation? target)
|
||||
{
|
||||
foreach (NetworkedStation station in stations.Values)
|
||||
{
|
||||
station.IsCallStackTarget = ReferenceEquals(station, target);
|
||||
}
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Tells every station to start its serial numbers again at one.
|
||||
public Task<int> SendResetSerialNumbersAsync(CancellationToken cancellation = default) =>
|
||||
SendAsync(StationMessages.ResetSerialNumbers(StationNumber, computerName), cancellation);
|
||||
|
||||
/// Which contest this station is logging. It goes out when a connection
|
||||
/// opens and whenever the operator opens another contest.
|
||||
public Task<int> SendContestNameAsync(CancellationToken cancellation = default) =>
|
||||
Log is null
|
||||
? Task.FromResult(0)
|
||||
: SendAsync(
|
||||
StationMessages.ContestName(Log.ContestName, Log.ContestSubType, StationNumber, computerName),
|
||||
cancellation);
|
||||
|
||||
/// Hands a callsign to another station's call stack. With a contact behind
|
||||
/// it the other end gets the frequency and the exchange as well.
|
||||
public Task<bool> SendStackedAsync(
|
||||
NetworkedStation to,
|
||||
string call,
|
||||
Qso? contact = null,
|
||||
CancellationToken cancellation = default) =>
|
||||
SendToAsync(
|
||||
to,
|
||||
contact is null
|
||||
? StationMessages.StackCall(call, StationNumber, computerName)
|
||||
: StationMessages.StackContact(contact, StationNumber, computerName),
|
||||
cancellation);
|
||||
|
||||
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
||||
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
@@ -401,6 +485,7 @@ public sealed class StationLink : IDisposable
|
||||
try
|
||||
{
|
||||
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
|
||||
myAddress = beacon.Address;
|
||||
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
|
||||
await beacons!
|
||||
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
|
||||
@@ -462,6 +547,19 @@ public sealed class StationLink : IDisposable
|
||||
/// opened to.
|
||||
private void Heard(StationBeacon beacon, string from)
|
||||
{
|
||||
if (string.Equals(beacon.ComputerName, computerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// our own beacon comes back to us: every other computer answering
|
||||
// to this name is a second station calling itself the same thing,
|
||||
// and their contacts would be stamped with the same name
|
||||
if (myAddress.Length > 0 && from != myAddress && warnedAboutName.Add(from))
|
||||
{
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"another computer at {from} is also calling itself {computerName} — give one of them another name");
|
||||
}
|
||||
return;
|
||||
}
|
||||
NetworkedStation station = stations.GetOrAdd(
|
||||
beacon.ComputerName,
|
||||
name => new NetworkedStation(name, from, beacon.Port));
|
||||
@@ -513,6 +611,15 @@ public sealed class StationLink : IDisposable
|
||||
// the others were told when their own connection opened
|
||||
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
// which contest this station is logging, so the other end can say so
|
||||
// before the two of them resynchronise into the wrong log
|
||||
if (Log is not null)
|
||||
{
|
||||
await SendToAsync(
|
||||
station,
|
||||
StationMessages.ContestName(Log.ContestName, Log.ContestSubType, StationNumber, computerName),
|
||||
cancellation).ConfigureAwait(false);
|
||||
}
|
||||
// when this station last worked anybody. The other end holds contacts
|
||||
// of ours only up to some time of its own, and starts a resync when
|
||||
// this one is later
|
||||
@@ -532,6 +639,81 @@ public sealed class StationLink : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart two clocks may be before it is worth saying so. N1MM
|
||||
/// ignores a second either way.
|
||||
private static readonly TimeSpan ClockPatience = TimeSpan.FromSeconds(2);
|
||||
|
||||
private void MarkMaster(string name)
|
||||
{
|
||||
foreach (NetworkedStation station in stations.Values)
|
||||
{
|
||||
station.IsMaster = string.Equals(station.ComputerName, name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Says so when another station is logging a different contest. It is
|
||||
/// worth saying twice: the contacts arriving from it are being scored into
|
||||
/// this log against rules that are not theirs.
|
||||
private void WarnAboutContest(NetworkedStation station, int fieldCount)
|
||||
{
|
||||
if (Log is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// N1MM counts the fields of this message and says a station with fewer
|
||||
// of them is on another version. The trailing separator leaves an empty
|
||||
// one on the end, which is the fourth
|
||||
if (fieldCount < 4)
|
||||
{
|
||||
Failed?.Invoke(this, $"{station.ComputerName} is running another version of the program");
|
||||
}
|
||||
if (!string.Equals(station.ContestName, Log.ContestName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"{station.ComputerName} is logging {station.ContestName}, this station is logging {Log.ContestName}");
|
||||
}
|
||||
else if (station.ContestSubType.Length > 0
|
||||
&& Log.ContestSubType.Length > 0
|
||||
&& !string.Equals(station.ContestSubType, Log.ContestSubType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"{station.ComputerName} is logging {station.ContestName} for {station.ContestSubType}, this station for {Log.ContestSubType}");
|
||||
}
|
||||
}
|
||||
|
||||
private void WarnAboutClock(NetworkedStation station)
|
||||
{
|
||||
if (station.ClockOffset is not { } offset || offset.Duration() <= ClockPatience)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string which = offset > TimeSpan.Zero ? "ahead of" : "behind";
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"{station.ComputerName}'s clock is {offset.Duration().TotalSeconds:0} s {which} this one — set one of them");
|
||||
}
|
||||
|
||||
/// Says so once when another station is scoring with a different issue of
|
||||
/// the country file. Two issues score the same contact differently, and the
|
||||
/// two stations then disagree about the score all contest.
|
||||
private void WarnAboutCountryFile(NetworkedStation station)
|
||||
{
|
||||
if (Log is null
|
||||
|| station.CountryFileVersion.Length == 0
|
||||
|| Log.CountryFileVersion.Length == 0
|
||||
|| station.CountryFileVersion == Log.CountryFileVersion
|
||||
|| !warnedAboutCountryFile.Add(station.ComputerName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"{station.ComputerName} has country file {station.CountryFileVersion}, this station has {Log.CountryFileVersion}");
|
||||
}
|
||||
|
||||
/// A station said when it last worked anybody. When this station holds
|
||||
/// nothing of its that late, the hours in between are missing and a resync
|
||||
/// asks for them.
|
||||
@@ -642,6 +824,12 @@ public sealed class StationLink : IDisposable
|
||||
{
|
||||
case "IAM":
|
||||
station.StationNumber = record.Number(0);
|
||||
if (station.StationNumber == StationNumber && !station.IsMine)
|
||||
{
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"{station.ComputerName} is station {station.StationNumber} as well — every station of an entry needs its own number");
|
||||
}
|
||||
break;
|
||||
case "FREQ":
|
||||
station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
|
||||
@@ -658,7 +846,9 @@ public sealed class StationLink : IDisposable
|
||||
station.Mode = status.Mode;
|
||||
station.Last10 = status.Last10;
|
||||
station.Last100 = status.Last100;
|
||||
station.CountryFileVersion = status.CountryFileVersion;
|
||||
station.IsRun2 = status.IsRun2;
|
||||
WarnAboutCountryFile(station);
|
||||
break;
|
||||
case "XMIT":
|
||||
station.IsTransmitting = record.Flag(0);
|
||||
@@ -688,6 +878,28 @@ public sealed class StationLink : IDisposable
|
||||
UpdateArrived?.Invoke(this, cleared);
|
||||
}
|
||||
break;
|
||||
case "CONTESTNAME":
|
||||
station.ContestName = record.Field(1);
|
||||
station.ContestSubType = record.Field(2);
|
||||
WarnAboutContest(station, record.Fields.Count);
|
||||
break;
|
||||
case "TIME":
|
||||
station.ClockOffset = record.Time(0) - DateTime.UtcNow;
|
||||
WarnAboutClock(station);
|
||||
break;
|
||||
case "MASTER":
|
||||
MarkMaster(record.Field(0));
|
||||
break;
|
||||
case "RESETQSONRS":
|
||||
SerialNumbersReset?.Invoke(this, EventArgs.Empty);
|
||||
break;
|
||||
case "STACKANOTHERCALL":
|
||||
CallStacked?.Invoke(this, new StackedCall(record.Field(0), null, station.ComputerName));
|
||||
break;
|
||||
case "STACKCALL":
|
||||
Qso stacked = QsoRecord.Read(record, 0, station.ComputerName);
|
||||
CallStacked?.Invoke(this, new StackedCall(stacked.Call.Text, stacked, station.ComputerName));
|
||||
break;
|
||||
case "TALK":
|
||||
TalkArrived?.Invoke(this, record.Field(0));
|
||||
break;
|
||||
|
||||
@@ -23,6 +23,12 @@ namespace Nonemm.Network;
|
||||
/// | `XMIT` | a station started or stopped transmitting | yes | yes |
|
||||
/// | `FREQ` | where a station is tuned | yes | yes |
|
||||
/// | `LASTQAT` | the time of the sender's last contact, which starts a resync | yes | yes |
|
||||
/// | `CONTESTNAME` | which contest the sender is logging | yes | yes |
|
||||
/// | `TIME` | the sender's clock | yes | yes |
|
||||
/// | `MASTER` | which station is the master for the cluster and the time | yes | yes |
|
||||
/// | `RESETQSONRS` | start the serial numbers again at one | yes | yes |
|
||||
/// | `STACKCALL` | a contact for another station's call stack | yes | yes |
|
||||
/// | `STACKANOTHERCALL` | a callsign for another station's call stack | yes | yes |
|
||||
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
||||
/// | `CHECKSUM` | the checksum of one block of the log, which starts a resync | yes | yes |
|
||||
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
||||
@@ -174,7 +180,7 @@ public static class StationMessages
|
||||
status.Mode?.Name ?? "",
|
||||
status.OperatorCategory,
|
||||
status.TransmitterCategory,
|
||||
"",
|
||||
status.CountryFileVersion,
|
||||
version,
|
||||
StationRecord.Written(false),
|
||||
StationRecord.Written(false),
|
||||
@@ -197,6 +203,7 @@ public static class StationMessages
|
||||
record.Number(5),
|
||||
record.Field(7),
|
||||
record.Field(8),
|
||||
record.Field(9),
|
||||
record.Flag(16));
|
||||
|
||||
/// The checksum of one block of the log, which is how a resync asks
|
||||
@@ -270,6 +277,46 @@ public static class StationMessages
|
||||
computerName,
|
||||
[StationRecord.Written(fromUtc), ofComputerName, Text(minutes)]);
|
||||
|
||||
/// Which contest this station is logging, sent as soon as a connection
|
||||
/// opens. Two stations logging different contests would resynchronise each
|
||||
/// other's contacts into the wrong log, so each says which it is on and the
|
||||
/// other tells its operator when they differ.
|
||||
///
|
||||
/// N1MM counts the fields: a message with fewer than four is from a program
|
||||
/// on another version, and it says so rather than guessing.
|
||||
public static StationRecord ContestName(
|
||||
string contestName,
|
||||
string subType,
|
||||
int stationNumber,
|
||||
string computerName) =>
|
||||
Record("CONTESTNAME", stationNumber, computerName, [computerName, contestName, subType]);
|
||||
|
||||
/// The sender's clock. The station that reads it compares the time with its
|
||||
/// own: contacts logged at two stations whose clocks disagree cannot be
|
||||
/// matched up by the sponsor.
|
||||
public static StationRecord Time(DateTime nowUtc, int stationNumber, string computerName) =>
|
||||
Record("TIME", stationNumber, computerName, [StationRecord.Written(nowUtc)]);
|
||||
|
||||
/// Which station is the master: the one whose cluster spots the others
|
||||
/// take and whose clock the others follow.
|
||||
public static StationRecord Master(string masterName, int stationNumber, string computerName) =>
|
||||
Record("MASTER", stationNumber, computerName, [masterName]);
|
||||
|
||||
/// Start the serial numbers again at one. It is sent when an operator finds
|
||||
/// the entry has been logging with numbers from a contest before this one.
|
||||
public static StationRecord ResetSerialNumbers(int stationNumber, string computerName) =>
|
||||
Record("RESETQSONRS", stationNumber, computerName, []);
|
||||
|
||||
/// A contact for another station's call stack: a station this one has heard
|
||||
/// but is not going to work, handed to the position that will.
|
||||
public static StationRecord StackContact(Qso qso, int stationNumber, string computerName) =>
|
||||
Record("STACKCALL", stationNumber, computerName, QsoRecord.Write(qso));
|
||||
|
||||
/// A callsign for another station's call stack, without a contact behind
|
||||
/// it. It is what an operator sends when he has nothing but the call.
|
||||
public static StationRecord StackCall(string call, int stationNumber, string computerName) =>
|
||||
Record("STACKANOTHERCALL", stationNumber, computerName, [call]);
|
||||
|
||||
/// What a message that has arrived means, or null for one this program does
|
||||
/// nothing with.
|
||||
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
||||
|
||||
@@ -15,7 +15,9 @@ namespace Nonemm.Network;
|
||||
/// hundred contacts. N1MM shows both, and the pair is how an operator sees a
|
||||
/// station that has stopped working anybody without reading its log. `IsRun2`
|
||||
/// is true when the station is the second run radio of a two-radio position,
|
||||
/// which is the `R/M` column.
|
||||
/// which is the `R/M` column. `CountryFileVersion` is the issue of the country
|
||||
/// file the station is scoring with: two stations on different issues score the
|
||||
/// same contact differently, so each says which it has.
|
||||
public sealed record StationStatus(
|
||||
Frequency PassFrequency,
|
||||
Frequency Frequency,
|
||||
@@ -26,4 +28,5 @@ public sealed record StationStatus(
|
||||
int Last100,
|
||||
string OperatorCategory = "",
|
||||
string TransmitterCategory = "",
|
||||
string CountryFileVersion = "",
|
||||
bool IsRun2 = false);
|
||||
|
||||
Reference in New Issue
Block a user