Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ All notable changes to UnoSim. Versions follow [Semantic Versioning](https://sem
questions and dialogs fall back to the free Tutor without a Course Content
session instead of failing with `INVALID_REQUEST` (Course Content SSOT §11,
case G).
- **Sketches with `typedef` types, constant array sizes or default arguments
in function signatures run.** The generated function prototypes now sit
directly before the first function the sketch defines, without default
arguments, as in the Arduino IDE. Before, they stood at the top of the
file, so `Vektor add(Vektor a, Vektor b)`, `void f(double m[M][N])` and
`void f(byte precision = 2)` compiled with the Arduino compiler but failed
in the simulation.
- **An open `INPUT_PULLUP` pin reads `HIGH`.** The simulation now models the
internal pull-up resistor; before, such a pin read `LOW` as if a button
were pressed.

### Documentation

- The user guide lists the known differences between the simulation and a
real Arduino Uno (section "Grenzen der Simulation"): `int` is 32 bits wide
instead of 16, `long` and `double` are 64 bits, `Serial.print` of a float
without a precision prints `2.5` instead of `2.50`, `Serial.parseInt()`
returns at once instead of waiting for its timeout, and loop speed, memory
size and `random()` sequences differ. Point students to that section.

## v1.0.0 – 2026-10-09

Expand Down
32 changes: 32 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ Unter **File → Settings** findest du außerdem:
- **Pin Monitor**: Pin-Übersicht über dem Board,
- **Toast Duration**: Anzeigedauer von Hinweisen.

## Grenzen der Simulation

UnoSim prüft jeden Sketch zuerst mit dem echten Arduino-Compiler für den Uno.
Was hier kompiliert, kompiliert also auch auf der Hardware. Ausgeführt wird der
Sketch danach aber nicht als Uno-Programm, sondern als Programm für den
UnoSim-Server. Dadurch weicht die Simulation in einigen Punkten vom echten
Arduino Uno ab:

| Thema | Arduino Uno | UnoSim |
|---|---|---|
| Größe von `int` und `unsigned int` | 16 Bit: `int` reicht von −32768 bis 32767 | 32 Bit: `32767 + 1` ergibt 32768 statt −32768 |
| Größe von `long` | 32 Bit | 64 Bit |
| Größe von `double` | 32 Bit, gleich `float` | 64 Bit, rechnet genauer |
| `sizeof` | z. B. `sizeof(int)` = 2 | zeigt die Größen des Servers, z. B. 4 |
| `Serial.print` einer Kommazahl ohne Stellenangabe | `2.50` (immer 2 Nachkommastellen) | `2.5` |
| `Serial.parseInt()` ohne Eingabe | wartet 1 Sekunde, liefert dann 0 | liefert sofort 0 |
| Geschwindigkeit | 16 MHz | anders: Wie oft `loop()` pro Sekunde läuft, weicht ab. `millis()` und `delay()` stimmen. |
| Arbeitsspeicher | 2 KB | praktisch unbegrenzt: Ein großes Array kann hier laufen und auf dem Uno abstürzen |
| `random()` | feste Zahlenfolge des Uno | andere Zahlenfolge |

Was das für deine Programme bedeutet:

- Überläufe beobachtest du zuverlässig mit `byte` (0 bis 255); bei `int`
und `long` zeigt UnoSim die Grenzen des Uno nicht.
- Gib Kommazahlen immer mit Stellenzahl aus, z. B. `Serial.println(wert, 2)`;
dann ist die Ausgabe auf Uno und UnoSim gleich.
- Prüfe Programme, die von Wertebereichen, Speicher oder genauer Rechenzeit
abhängen, zusätzlich auf einem echten Board.

Gesendeter Text im Serial Monitor endet immer mit dem Zeilenende (Code 10),
wie im Serial Monitor der Arduino IDE mit der Einstellung „New Line“.

## Wenn etwas nicht klappt

| Meldung oder Verhalten | Ursache und Lösung |
Expand Down
9 changes: 8 additions & 1 deletion server/mocks/arduino-mock/arduino-registry-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,18 @@ void outputIORegistry() {
// GPIO Functions with state tracking
void pinMode(int pin, int mode) {
if (pin >= 0 && pin < 20) {
bool enablesPullup = mode == INPUT_PULLUP && pinModes[pin] != INPUT_PULLUP;
pinModes[pin] = mode;
// Send pin state update via stderr (special protocol)
{ std::lock_guard<std::mutex> lock(cerrMutex);
std::cerr << "[[PIN_MODE:" << pin << ":" << mode << "]]" << std::endl; }


// The internal pull-up resistor makes an open input read HIGH until it is pulled LOW externally.
if (enablesPullup && pinValues[pin].exchange(HIGH, std::memory_order_seq_cst) != HIGH) {
std::lock_guard<std::mutex> lock(cerrMutex);
std::cerr << "[[PIN_VALUE:" << pin << ":" << HIGH << "]]" << std::endl;
}

// Track in I/O Registry - add pinMode as an operation with mode info
if (ioRegistry.find(pin) != ioRegistry.end()) {
ioRegistry[pin].defined = true;
Expand Down
45 changes: 12 additions & 33 deletions server/services/sketch-file-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { detectSketchEntrypoints } from "@shared/utils/sketch-validation";
import { isSafeHeaderName } from "@shared/input-limits";
import { resolvePathWithinRoot } from "../security/safe-paths";
import { normalizeSourcePath, resolveSourceProject } from "@shared/source-project";
import { insertForwardDeclarations, planForwardDeclarations } from "./sketch-prototypes";

interface SketchBuildResult {
sketchDir: string;
Expand All @@ -21,18 +22,7 @@ interface SketchBuildResult {
}

export function extractForwardDeclarations(code: string): string {
const stripped = code.replaceAll(/\/\/[^\n]*/g, "").replaceAll(/\/\*[\s\S]*?\*\//g, "");
const skipped = new Set(["if", "else", "while", "for", "do", "switch", "case", "return", "break", "continue", "goto", "class", "struct", "union", "enum", "namespace", "typedef", "setup", "loop", "main"]);
const definition = /^((?:\w[\w*&]*\s+)*\w[\w*&]*)\s+(\w+)\s*(\([^)]*\))\s*\{/gm;
const seen = new Set<string>();
const declarations: string[] = [];
for (const match of stripped.matchAll(definition)) {
const signature = `${match[2]}${match[3]}`;
if (skipped.has(match[2]) || seen.has(signature)) continue;
seen.add(signature);
declarations.push(`${match[1].trim()} ${match[2]}${match[3]};`);
}
return declarations.join("\n");
return planForwardDeclarations(code).declarations;
}

export class SketchFileBuilder {
Expand Down Expand Up @@ -76,7 +66,7 @@ export class SketchFileBuilder {
}

const footer = this.buildFooter(hasSetup, hasLoop);
const projectSource = entryFile
const project = entryFile
? resolveSourceProject({
entryFile,
files: {
Expand All @@ -86,16 +76,17 @@ export class SketchFileBuilder {
return files;
}, {}),
},
}).source
: code;
})
: undefined;
const projectSource = project ? project.source : code;
// Removing the include keeps every line in place, so lineOrigins still match.
const cleanedCode = projectSource.replaceAll(/#include\s*[<"]Arduino\.h[>"]/g, "");
const isSketchLine = project
? (lineIndex: number) => project.lineOrigins[lineIndex]?.file === project.entryFile
: undefined;
const userCode = insertForwardDeclarations(cleanedCode, isSketchLine);

const forwardDecls = this.extractForwardDeclarations(cleanedCode);
const forwardSection = forwardDecls
? `// --- Forward declarations (auto-generated, mirrors Arduino IDE behaviour) ---\n${forwardDecls}\n\n`
: "";

const combined = `#include <Arduino.h>\n\n${forwardSection}// --- User code follows ---\n${cleanedCode}\n\n// --- Footer ---\n${footer}`;
const combined = `#include <Arduino.h>\n\n// --- User code follows ---\n${userCode}\n\n// --- Footer ---\n${footer}`;

await writeFile(join(sketchDir, "Arduino.h"), ARDUINO_MOCK_CODE);
await writeFile(sketchFile, combined);
Expand All @@ -121,18 +112,6 @@ export class SketchFileBuilder {
this.createdSketchDirs.delete(dir);
}

/**
* Extracts forward declarations for all user-defined functions.
*
* The Arduino IDE automatically generates prototypes for every function in a
* sketch so that helper functions can be called before they are defined (just
* like in a real Arduino sketch). We replicate that behaviour here so that
* the local g++ compiler accepts the same code the Arduino IDE would accept.
*/
private extractForwardDeclarations(code: string): string {
return extractForwardDeclarations(code);
}

/**
* Generates the main() wrapper based on presence of setup() and loop()
*/
Expand Down
146 changes: 146 additions & 0 deletions server/services/sketch-prototypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Arduino-IDE-compatible function prototypes for the local g++ build.
*
* The Arduino IDE declares every sketch function before the first function
* definition, so a helper may be called before it is defined. The prototypes
* go there, not at the top of the file, because their signatures may use
* types and constants that the sketch defines first (a `typedef`, or `M` in
* `double mat[M][N]`). Default arguments are left out: C++ allows them only
* once, and the definition already carries them.
*/

import { maskCommentsPreservingLines } from "@shared/source-project";

const FUNCTION_DEFINITION = /^((?:\w[\w*&]*\s+)*\w[\w*&]*)\s+(\w+)\s*(\([^)]*\))\s*\{/gm;
const NOT_A_FUNCTION = new Set(["if", "else", "while", "for", "do", "switch", "case", "return", "break", "continue", "goto", "class", "struct", "union", "enum", "namespace", "typedef"]);
const ENTRYPOINTS = new Set(["setup", "loop", "main"]);
const OPENING_BRACKETS = "([{";
const CLOSING_BRACKETS = ")]}";

export interface ForwardDeclarationPlan {
/** Newline-separated prototypes, empty when the sketch needs none. */
declarations: string;
/** Offset in the code where the prototypes belong; -1 when there is no function definition. */
insertAt: number;
}

interface ParameterScan {
parts: string[];
current: string;
depth: number;
quote: string | null;
escaped: boolean;
}

function scanQuoted(scan: ParameterScan, character: string): void {
scan.current += character;
if (scan.escaped) scan.escaped = false;
else if (character === "\\") scan.escaped = true;
else if (character === scan.quote) scan.quote = null;
}

function scanParameterCharacter(scan: ParameterScan, character: string): void {
if (scan.quote) {
scanQuoted(scan, character);
return;
}
if (character === "," && scan.depth === 0) {
scan.parts.push(scan.current);
scan.current = "";
return;
}
if (character === '"' || character === "'") scan.quote = character;
else if (OPENING_BRACKETS.includes(character)) scan.depth++;
else if (CLOSING_BRACKETS.includes(character)) scan.depth--;
scan.current += character;
}

/** Index of the `=` that starts a default argument, ignoring `=` inside quotes or brackets. */
function defaultArgumentStart(parameter: string): number {
let depth = 0;
let quote: string | null = null;
for (let index = 0; index < parameter.length; index++) {
const character = parameter[index];
if (quote) {
if (character === "\\") index++;
else if (character === quote) quote = null;
} else if (character === '"' || character === "'") {
quote = character;
} else if (OPENING_BRACKETS.includes(character)) {
depth++;
} else if (CLOSING_BRACKETS.includes(character)) {
depth--;
} else if (character === "=" && depth === 0) {
return index;
}
}
return -1;
}

/** Removes default arguments from a parenthesized parameter list such as `(int a, byte b = 2)`. */
export function stripDefaultArguments(parameterList: string): string {
const scan: ParameterScan = { parts: [], current: "", depth: 0, quote: null, escaped: false };
for (const character of parameterList.slice(1, -1)) scanParameterCharacter(scan, character);
scan.parts.push(scan.current);
if (!scan.parts.some((part) => defaultArgumentStart(part) >= 0)) return parameterList;
const parameters = scan.parts.map((part) => {
const start = defaultArgumentStart(part);
return (start >= 0 ? part.slice(0, start) : part).trim();
});
return `(${parameters.join(", ")})`;
}

function lineIndexAt(code: string, offset: number): number {
let line = 0;
for (let index = code.indexOf("\n"); index >= 0 && index < offset; index = code.indexOf("\n", index + 1)) line++;
return line;
}

/**
* Plans the prototypes for every function defined in `code`.
*
* `isSketchLine` tells which lines belong to the sketch itself rather than to
* an inlined header; the prototypes go before the first function the sketch
* defines, like in the Arduino IDE. Without a sketch function they go before
* the first function at all.
*/
export function planForwardDeclarations(
code: string,
isSketchLine: (lineIndex: number) => boolean = () => true,
): ForwardDeclarationPlan {
const masked = maskCommentsPreservingLines(code);
const seen = new Set<string>();
const declarations: string[] = [];
let firstDefinition = -1;
let firstSketchDefinition = -1;

for (const match of masked.matchAll(FUNCTION_DEFINITION)) {
const name = match[2];
if (NOT_A_FUNCTION.has(name)) continue;
const offset = match.index ?? 0;
if (firstDefinition < 0) firstDefinition = offset;
if (firstSketchDefinition < 0 && isSketchLine(lineIndexAt(masked, offset))) firstSketchDefinition = offset;
if (ENTRYPOINTS.has(name)) continue;
const parameters = stripDefaultArguments(match[3]);
const signature = `${name}${parameters}`;
if (seen.has(signature)) continue;
seen.add(signature);
declarations.push(`${match[1].trim()} ${signature};`);
}

return {
declarations: declarations.join("\n"),
insertAt: firstSketchDefinition >= 0 ? firstSketchDefinition : firstDefinition,
};
}

/** Inserts the planned prototypes into `code`; returns `code` unchanged when none are needed. */
export function insertForwardDeclarations(
code: string,
isSketchLine?: (lineIndex: number) => boolean,
): string {
const plan = planForwardDeclarations(code, isSketchLine);
if (!plan.declarations || plan.insertAt < 0) return code;
const section = `// --- Forward declarations (auto-generated, mirrors Arduino IDE behaviour) ---\n${plan.declarations}\n\n`;
return `${code.slice(0, plan.insertAt)}${section}${code.slice(plan.insertAt)}`;
}
3 changes: 2 additions & 1 deletion shared/source-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ function advanceStringMask(state: StringMaskState, current: string): StringMaskS
return state;
}

function maskCommentsPreservingLines(source: string): string {
/** Replaces comment characters with spaces, keeping every offset and line break of `source`. */
export function maskCommentsPreservingLines(source: string): string {
const result: string[] = [];
let inBlockComment = false;
let stringState: StringMaskState = { quote: null, escaped: false };
Expand Down
Loading
Loading