diff --git a/indra/alscript/allsltoslua.cpp b/indra/alscript/allsltoslua.cpp index c641f4fd03..4fb8f81e45 100644 --- a/indra/alscript/allsltoslua.cpp +++ b/indra/alscript/allsltoslua.cpp @@ -386,6 +386,10 @@ namespace // Whether every read of a counter in a loop's body is an index that // ll is given (llIndex), which the loop may count from 1 for. bool onlyIndexes(LSLSymbol* var, LSLASTNode* body); + // Whether every read of a counter in a loop's body is of one list's + // item at it, as it is (listItemFits): the loop may walk the list's + // items instead (mEach). + bool onlyItemsOf(LSLSymbol* var, LSLSymbol* list, LSLASTNode* body); // The call's arguments for ll: its index arguments moved on by one. std::string llArgs(LSLFunctionExpression* e, U16 indexes); // `: type` for an LSL type, where types are written. @@ -510,6 +514,8 @@ namespace static std::vector appended(LSLASTNode* node, LSLSymbol*& var); // A name no other in the script has, nor SLua holds. std::string freshName(const std::string& base); + // Every name the script has, and those made fresh, in mTaken. + void takeNames(); // --- steps as statements ----------------------------------------------------- @@ -634,6 +640,11 @@ namespace boost::unordered_flat_set mFromOne; // Counters within a list's length, i < llGetListLength(l): the list. boost::unordered_flat_map mWithin; + // Counters whose loop walks their list's items instead: the item's + // name. A name for each depth of such loops, made once. + boost::unordered_flat_map mEach; + std::vector mItemNames; + std::string mUnread; boost::unordered_flat_map mListTypes; boost::unordered_flat_set mLoopOnly; boost::unordered_flat_set mBooleans; @@ -651,6 +662,11 @@ namespace // The strings each loop builds, and those being built, by the // table of their pieces. boost::unordered_flat_map> mBuilds; + // A string a loop builds, declared empty in the loop's own block + // with nothing between that touches it: declared where its pieces + // are joined instead, its declaration not written. + boost::unordered_flat_set mJoinedDeclarations; + boost::unordered_flat_set> mDeclaredAtJoin; boost::unordered_flat_map mBuilding; // Appends in loops that are noted rather than built. boost::unordered_flat_set mUnbuilt; @@ -953,13 +969,15 @@ namespace int at = 0; for (LSLASTNode* arg = isNull(list) ? nullptr : list->getChild(0); arg; arg = arg->getNext(), ++at) { - auto* given = static_cast(arg); - LSLIType to = param ? varType(static_cast(param)->getSymbol(), param->getIType()) : given->getIType(); + auto* given = static_cast(arg); + LSLSymbol* taken = param ? static_cast(param)->getSymbol() : nullptr; + LSLIType to = param ? varType(taken, param->getIType()) : given->getIType(); if ((text & (1 << at)) && (slType(given) == LST_STRING || slType(given) == LST_KEY)) { to = slType(given); } - out += (out.empty() ? "" : ", ") + coerced(given, to).text; + // A parameter the script only reads as a truth is given one. + out += (out.empty() ? "" : ", ") + (boolean(taken) ? truthOf(given) : coerced(given, to).text); param = param ? param->getNext() : nullptr; } return out; @@ -2155,6 +2173,11 @@ namespace { return; } + // A string declared where the loop building it joins it. + if (mJoinedDeclarations.contains(d)) + { + return; + } const Steps steps = isNull(init) ? Steps() : hoistSteps(init, true); writeSteps(steps.before); if (boolean(id->getSymbol())) @@ -2568,20 +2591,46 @@ namespace : shift < 0 ? value(c.limit).text : bracketed(value(c.limit), ADD) + (shift > 0 ? " + 2" : " + 1"); } - line("for " + nameOf(c.id) + " = " + start + ", " + to + step + " do"); - if (up || down) - { - mNonNegative.insert(c.var); - } // Up by one, below a list's length: within the list. LSLExpression* limit_call = unwrapped(c.limit); + LSLExpression* listed = nullptr; if (up && c.step == 1 && c.check == OP_LESS && limit_call->getNodeSubType() == NODE_FUNCTION_EXPRESSION && std::string_view(static_cast(limit_call)->getIdentifier()->getName()) == "llGetListLength") { - if (LSLSymbol* list = wholeVariable(argumentAt(static_cast(limit_call), 0))) + listed = argumentAt(static_cast(limit_call), 0); + } + LSLSymbol* within = listed ? wholeVariable(listed) : nullptr; + // From its first item, the counter read as nothing but the list's + // items: Luau's walk over them, each by name. + const bool each = from_one && within && from == 0 && onlyItemsOf(c.var, within, f->getBody()); + if (each) + { + const size_t depth = mEach.size(); + if (depth == mItemNames.size()) + { + mItemNames.push_back(freshName("item")); + } + // The index unread, as Luau writes one, unless the script has + // a name that would be hidden by it. + if (mUnread.empty()) { - mWithin[c.var] = list; + takeNames(); + mUnread = mTaken.contains("_") ? freshName("_") : std::string("_"); } + line("for " + mUnread + ", " + mItemNames[depth] + " in " + lvalue(static_cast(unwrapped(listed))).text + " do"); + mEach[c.var] = mItemNames[depth]; + } + else + { + line("for " + nameOf(c.id) + " = " + start + ", " + to + step + " do"); + } + if (up || down) + { + mNonNegative.insert(c.var); + } + if (within) + { + mWithin[c.var] = within; } if (from_one) { @@ -2596,6 +2645,7 @@ namespace mNonNegative.erase(c.var); mFromOne.erase(c.var); mWithin.erase(c.var); + mEach.erase(c.var); line("end"); } @@ -2653,6 +2703,39 @@ namespace return any && only; } + bool Writer::onlyItemsOf(LSLSymbol* var, LSLSymbol* list, LSLASTNode* body) + { + bool any = false; + bool only = true; + walk(body, [&](LSLASTNode* node) { + if (!only || node->getNodeSubType() != NODE_LVALUE_EXPRESSION || + static_cast(node)->getIdentifier()->getSymbol() != var) + { + return; + } + any = true; + // As it is, the index of a library call reading an item of the + // list. + LSLASTNode* arg = node; + while (arg->getParent() && arg->getParent()->getNodeSubType() == NODE_PARENTHESIS_EXPRESSION) + { + arg = arg->getParent(); + } + LSLASTNode* args = arg->getParent(); + LSLASTNode* call = args ? args->getParent() : nullptr; + if (!call || args->getNodeType() != NODE_AST_NODE_LIST || call->getNodeSubType() != NODE_FUNCTION_EXPRESSION || + arg->getParentSlot() != 1) + { + only = false; + return; + } + auto* read = static_cast(call); + LSLSymbol* fn = read->getIdentifier()->getSymbol(); + only = fn && fn->getSubType() == SYM_BUILTIN && wholeVariable(argumentAt(read, 0)) == list && listItemFits(read); + }); + return any && only; + } + // --- truth values ---------------------------------------------------------------- namespace @@ -2860,6 +2943,30 @@ namespace { mBooleans.insert(f->getSymbol()); } + // Its integer parameters, each set by what its calls give + // it. An event's are the grid's, typed as numbers there, + // and stay numbers. + for (LSLASTNode* param = f->getArguments() ? f->getArguments()->getChild(0) : nullptr; !isNull(param); param = param->getNext()) + { + candidate(static_cast(param), nullptr); + } + } + else if (node->getNodeSubType() == NODE_FUNCTION_EXPRESSION) + { + // A call of the script's own function: each argument what + // its parameter is set to. + auto* call = static_cast(node); + LSLSymbol* symbol = call->getIdentifier()->getSymbol(); + LSLParamList* params = symbol && symbol->getSubType() != SYM_BUILTIN ? symbol->getFunctionDecl() : nullptr; + LSLASTNode* param = params ? params->getChild(0) : nullptr; + for (LSLASTNode* arg = isNull(call->getArguments()) ? nullptr : call->getArguments()->getChild(0); arg && !isNull(param); + arg = arg->getNext(), param = param->getNext()) + { + if (LSLSymbol* set = static_cast(param)->getSymbol(); set && param->getIType() == LST_INTEGER) + { + sets.emplace_back(set, static_cast(arg)); + } + } } else if (node->getNodeSubType() == NODE_DECLARATION) { @@ -2874,7 +2981,7 @@ namespace sets.emplace_back(f, e); } } - else if ((node->getNodeSubType() == NODE_LVALUE_EXPRESSION || node->getNodeSubType() == NODE_FUNCTION_EXPRESSION) && readOf(node)) + if ((node->getNodeSubType() == NODE_LVALUE_EXPRESSION || node->getNodeSubType() == NODE_FUNCTION_EXPRESSION) && readOf(node)) { reads.push_back(node); } @@ -3608,12 +3715,17 @@ namespace appends.emplace_back(var, inner); auto* b = static_cast(inner); accounted.insert(unwrapped(b->getLHS())); - LSLExpression* sum = unwrapped(b->getRHS()); - while (b->getOperation() == OP_ASSIGN && sum->getNodeSubType() == NODE_BINARY_EXPRESSION) + // s = s + a: the s it adds to, which is the append's own. + // Nothing of s += a is: a string appended is read. + if (b->getOperation() == OP_ASSIGN) { - sum = unwrapped(static_cast(sum)->getLHS()); + LSLExpression* sum = unwrapped(b->getRHS()); + while (sum->getNodeSubType() == NODE_BINARY_EXPRESSION) + { + sum = unwrapped(static_cast(sum)->getLHS()); + } + accounted.insert(sum); } - accounted.insert(sum); }); if (appends.empty()) { @@ -3661,6 +3773,48 @@ namespace } } }); + // Declared empty just before the loop that builds it, in the same + // block, nothing between reading or setting it: declared at the join. + const auto touches = [](LSLASTNode* statement, LSLSymbol* var) { + bool seen = false; + walk(statement, [&](LSLASTNode* inner) { + seen = seen || (inner->getNodeSubType() == NODE_LVALUE_EXPRESSION && + static_cast(inner)->getIdentifier()->getSymbol() == var); + }); + return seen; + }; + for (const auto& [built_by, vars] : mBuilds) + { + LSLASTNode* block = built_by->getParent(); + if (!block || block->getNodeSubType() != NODE_COMPOUND_STATEMENT) + { + continue; + } + for (LSLSymbol* var : vars) + { + LSLDeclaration* declared = nullptr; + bool touched = false; + for (LSLASTNode* statement = block->getChild(0); statement && statement != built_by; statement = statement->getNext()) + { + if (statement->getNodeSubType() == NODE_DECLARATION && static_cast(statement)->getIdentifier()->getSymbol() == var) + { + declared = static_cast(statement); + touched = false; + } + else if (declared && touches(statement, var)) + { + touched = true; + } + } + if (declared && !touched && (isNull(declared->getInitializer()) || emptyValue(declared->getInitializer()))) + { + mJoinedDeclarations.insert(declared); + // By this loop alone: a later one building it adds to + // what this one declared. + mDeclaredAtJoin.insert({ built_by, var }); + } + } + } // An append an outer loop builds after all is not noted. boost::unordered::erase_if(mUnbuilt, [&](LSLASTNode* append) { LSLSymbol* var = nullptr; @@ -3679,23 +3833,30 @@ namespace { const std::vector vars = std::move(mBuilds[loop]); mBuilds.erase(loop); - std::vector> joins; + struct Join + { + std::string name; + std::string parts; + bool declared = false; + }; + std::vector joins; for (LSLSymbol* var : vars) { const std::string name = mNames.contains(var) ? mNames[var] : nameOf(var->getName()); const std::string parts = freshName(name + "Parts"); line("local " + parts + (mOptions.types ? ": { string }" : "") + " = {}"); mBuilding[var] = parts; - joins.emplace_back(name, parts); + joins.push_back({ name, parts, mDeclaredAtJoin.contains({ loop, var }) }); } statement(loop, last); for (LSLSymbol* var : vars) { mBuilding.erase(var); } - for (const auto& [name, parts] : joins) + for (const Join& join : joins) { - line(name + " ..= table.concat(" + parts + ")"); + line(join.declared ? "local " + join.name + (mOptions.types ? ": string" : "") + " = table.concat(" + join.parts + ")" + : join.name + " ..= table.concat(" + join.parts + ")"); } } @@ -3741,7 +3902,7 @@ namespace return true; } - std::string Writer::freshName(const std::string& base) + void Writer::takeNames() { if (mTaken.empty()) { @@ -3753,6 +3914,11 @@ namespace } }); } + } + + std::string Writer::freshName(const std::string& base) + { + takeNames(); std::string name = base; for (int n = 2; mTaken.contains(name) || reservedName(name); ++n) { @@ -4068,7 +4234,9 @@ namespace LSLSymbol* counter = wholeVariable(index); const auto within = counter ? mWithin.find(counter) : mWithin.end(); const bool found = within != mWithin.end() && within->second == var; - const std::string item = name + "[" + at + "]"; + // And where its loop walks the list's items, the item by name. + const auto walked = found ? mEach.find(counter) : mEach.end(); + const std::string item = walked != mEach.end() ? walked->second : name + "[" + at + "]"; const Expr read = found ? Expr{ item } : Expr{ item + " or " + kind->second.empty, OR }; return as_is ? read : Expr{ "tostring(" + read.text + ")" }; } @@ -4271,8 +4439,9 @@ namespace std::string params; for (LSLASTNode* p = f->getArguments() ? f->getArguments()->getChild(0) : nullptr; p; p = p->getNext()) { + LSLSymbol* param = static_cast(p)->getSymbol(); params += (params.empty() ? "" : ", ") + nameOf(static_cast(p)) + - typed(varType(static_cast(p)->getSymbol(), p->getIType())); + (boolean(param) ? std::string(mOptions.types ? ": boolean" : "") : typed(varType(param, p->getIType()))); } mFunction = f->getSymbol(); line(std::string(forward ? "function " : "local function ") + nameOf(f->getIdentifier()) + "(" + params + ")" + diff --git a/indra/alscript/alpreprocessor.cpp b/indra/alscript/alpreprocessor.cpp index 74646b7604..666a3135c5 100644 --- a/indra/alscript/alpreprocessor.cpp +++ b/indra/alscript/alpreprocessor.cpp @@ -287,12 +287,35 @@ namespace Tokens run(std::string_view text) { + // A line ends at CRLF and at a lone CR as at LF (5.1.1.2 phase + // 1), as the studio's own text does: lexed from a copy with them + // made LF, where text from a disk, an external editor or an + // inventory include has them -- else a backslash before CRLF + // joins nothing and its CR is a blank. + if (!mVerbatim && text.find('\r') != std::string_view::npos) + { + mUnixText.clear(); + mUnixText.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) + { + if (text[i] != '\r') + { + mUnixText += text[i]; + } + else if (i + 1 >= text.size() || text[i + 1] != '\n') + { + mUnixText += '\n'; + } + } + text = mUnixText; + } mText = text; mPos = 0; mLine = 0; mColumn = 0; mJoined = 0; mOut.clear(); + mSpacedJoins.clear(); while (mPos < mText.size()) { if (mLua) @@ -341,20 +364,64 @@ namespace // A backslash and a newline join two lines; both vanish, and the // newline that ends the joined line stands for them all, so that - // the lines after keep their numbers. + // the lines after keep their numbers. On a directive's line, blanks + // between the two are let by, as GCC and Clang let them by, with a + // warning: they are not seen, and what a #define then left behind + // went on as code. Not elsewhere, where a note ending in a + // backslash and a space would take the line of code after it. bool continuation() { - if (!mVerbatim && at(mPos) == '\\' && at(mPos + 1) == '\n') + if (mVerbatim || at(mPos) != '\\') { - mPos += 2; - ++mLine; - mColumn = 0; - ++mJoined; - return true; + return false; } - return false; + size_t newline = mPos + 1; + while (isBlank(at(newline))) + { + ++newline; + } + if (at(newline) != '\n') + { + return false; + } + if (newline > mPos + 1) + { + if (!onDirectiveLine()) + { + return false; + } + mSpacedJoins.push_back({ mLine, mColumn }); + } + mPos = newline + 1; + ++mLine; + mColumn = 0; + ++mJoined; + return true; } + // Whether the line being lexed -- since the last newline not + // joined away -- begins, past blanks and comments, with what starts + // a directive. + bool onDirectiveLine() const + { + const Token* first = nullptr; + for (auto it = mOut.rbegin(); it != mOut.rend() && it->kind != Kind::Newline; ++it) + { + if (it->kind != Kind::Space && it->kind != Kind::Comment) + { + first = &*it; + } + } + return first && first->kind == Kind::Punct && first->text == hashOf(mLua); + } + + public: + // Where a backslash was joined to its newline over blanks, by its + // line and column, for a warning. + const std::vector>& spacedJoins() const { return mSpacedJoins; } + + private: + // Every join where a token goes on: C joins the lines before it // reads a token (5.1.1.2 phase 2), so one runs on over a join -- // a name, a number, a directive's name, a // comment. LSL's alone: @@ -818,6 +885,9 @@ namespace S32 mJoined = 0; Token mToken; Tokens mOut; + // The text with its line ends made LF, where it had others. + std::string mUnixText; + std::vector> mSpacedJoins; }; // The text of a run of tokens, as written. @@ -1266,7 +1336,17 @@ namespace else { f->index = mResult.map.addFile(name, path); - f->lexed = std::make_shared(Lexer(mOptions.lua, f->index).run(text)); + Lexer lexer(mOptions.lua, f->index); + f->lexed = std::make_shared(lexer.run(text)); + for (const auto& [line, column] : lexer.spacedJoins()) + { + Token at; + at.file = f->index; + at.line = line; + at.column = column; + at.text = "\\"; + problem(ALScriptProblem::Severity::Warning, "PreprocBackslashSpace", "backslash and newline separated by space", {}, at); + } if (!path.empty()) { mLexed[path] = Lexed{ f->index, f->lexed }; @@ -5111,6 +5191,101 @@ void ALPreprocessor::optimize(Result& result, const Options& options) result.optimized = true; } +// static +bool ALPreprocessor::usesDirectives(const std::function& line, S32 count, bool lua) +{ + bool in_comment = false; + for (S32 i = 0; i < count; ++i) + { + const std::string_view text = line(i); + if (!in_comment && directiveName(text, lua) != std::string_view::npos) + { + return true; + } + if (lua) + { + continue; + } + // Where an LSL block comment opens or closes on the line, past its + // strings and a line comment. + bool in_string = false; + for (size_t c = 0; c < text.size(); ++c) + { + const bool pair = c + 1 < text.size(); + if (in_comment) + { + if (text[c] == '*' && pair && text[c + 1] == '/') + { + in_comment = false; + ++c; + } + } + else if (in_string) + { + if (text[c] == '\\') + { + ++c; + } + else if (text[c] == '"') + { + in_string = false; + } + } + else if (text[c] == '"') + { + in_string = true; + } + else if (text[c] == '/' && pair && text[c + 1] == '/') + { + break; + } + else if (text[c] == '/' && pair && text[c + 1] == '*') + { + in_comment = true; + ++c; + } + } + } + return false; +} + +// static +bool ALPreprocessor::usesDirectives(std::string_view text, bool lua) +{ + std::vector lines; + for (size_t at = 0; at <= text.size();) + { + const size_t end = text.find('\n', at); + lines.push_back(text.substr(at, (end == std::string_view::npos ? text.size() : end) - at)); + if (end == std::string_view::npos) + { + break; + } + at = end + 1; + } + return usesDirectives([&lines](S32 i) { return lines[static_cast(i)]; }, static_cast(lines.size()), lua); +} + +// static +ALPreprocessor::Wanted ALPreprocessor::wanted(bool enveloped, const std::function& directives, bool setting) +{ + if (enveloped) + { + return Wanted::Enveloped; + } + if (directives && directives()) + { + return Wanted::Directives; + } + return setting ? Wanted::Setting : Wanted::No; +} + +// static +ALPreprocessor::Wanted ALPreprocessor::wanted(std::string_view text, bool lua, bool enveloped, bool setting) +{ + return wanted(enveloped, [text, lua]() { return usesDirectives(text, lua); }, setting); +} + // static size_t ALPreprocessor::directiveName(std::string_view line, bool lua) { diff --git a/indra/alscript/alpreprocessor.h b/indra/alscript/alpreprocessor.h index efe07eb218..88595ba6c4 100644 --- a/indra/alscript/alpreprocessor.h +++ b/indra/alscript/alpreprocessor.h @@ -296,6 +296,26 @@ class ALPreprocessor // own name makes `--#` more than a comment. npos where the line holds // no directive. static size_t directiveName(std::string_view line, bool lua); + // Whether a script holds a directive: a line that is one, outside an + // LSL block comment. `line` answers a line's text by its index, `count` + // of them. + static bool usesDirectives(const std::function& line, S32 count, bool lua); + static bool usesDirectives(std::string_view text, bool lua); + + // Why a script is preprocessed as it goes up, or No, in this order: it + // went up in the preprocessor's envelope before, its source kept, and + // goes up as it went; it holds a directive, which the grid could + // compile no other way, whatever the setting; Preprocess on Save is on, + // for every script. `directives` is asked only where it decides. + enum class Wanted : U8 + { + No, + Enveloped, + Directives, + Setting + }; + static Wanted wanted(bool enveloped, const std::function& directives, bool setting); + static Wanted wanted(std::string_view text, bool lua, bool enveloped, bool setting); // The transform a line of LSL is written for, by the shape of its // first statement: `switch (` and `case ...:` the switch's; `break;`, diff --git a/indra/alscript/alscriptfixes.cpp b/indra/alscript/alscriptfixes.cpp index dcb59c51ee..8dff1afd97 100644 --- a/indra/alscript/alscriptfixes.cpp +++ b/indra/alscript/alscriptfixes.cpp @@ -853,9 +853,16 @@ namespace } if (!lua && line.front() == '#') { - // With the lines a backslash carries it on to. + // With the lines a backslash carries it on to, blanks after + // it let by as the preprocessor lets them by on a directive's + // line. + const auto carried = [&lines](S32 at) { + const std::string_view text = lines.line(at); + const size_t last = text.find_last_not_of(" \t\r\f\v"); + return last != std::string_view::npos && text[last] == '\\'; + }; last_kind = i; - while (!lines.line(last_kind).empty() && lines.line(last_kind).back() == '\\' && last_kind + 1 < lines.count()) + while (carried(last_kind) && last_kind + 1 < lines.count()) { ++last_kind; } diff --git a/indra/alscript/alscriptlintpass.cpp b/indra/alscript/alscriptlintpass.cpp index c252107958..4fc50a7470 100644 --- a/indra/alscript/alscriptlintpass.cpp +++ b/indra/alscript/alscriptlintpass.cpp @@ -70,8 +70,9 @@ namespace { "SlCompatCall", Rule::SLua, Severity::Note, true, true, nullptr, true }, // b == 1, where b is a boolean: LSL's truths were numbers. { "SlBooleanNumber", Rule::SLua, Severity::Error, true, true, nullptr, true }, - // x = 0 or function f() making a global; a function f() in a nested - // scope, lute's global_function_in_scope. + // x = 0 making a global at the top; a function f() in a nested scope, + // lute's global_function_in_scope. A function f() at the top is + // SlGlobalFunction's. { "SlGlobalAssign", Rule::SLua, Severity::Warning, true, true, nullptr, true }, // if (x) then: LSL's brackets, which Luau's if needs none of. { "SlParenCondition", Rule::SLua, Severity::Note, true, true, "parenthese_conditions", true }, @@ -104,6 +105,13 @@ namespace { "SlStringBuild", Rule::Both, Severity::Note, true, true, nullptr }, // llGetOwner() asked again and again in one handler. { "SlRepeatedCall", Rule::Both, Severity::Note, true, true, nullptr }, + // function f() at the top making a global: a rule of its own, which a + // script moved from LSL turns off while its functions still call + // those written after them, which a local function cannot be. + { "SlGlobalFunction", Rule::SLua, Severity::Note, true, true, nullptr, true }, + // for i = 1, n do ... i = j ... end: LSL's for went on from what i was + // set to, Luau's makes i afresh each time round. + { "SlForIndexAssign", Rule::SLua, Severity::Warning, true, false, nullptr, true }, }; const ALScriptLintPass::PrimParams PRIM_PARAMS[] = { @@ -427,6 +435,41 @@ namespace Luau::AstLocal* mLocal; }; + // Where a local is given a value in a stretch of the tree, plainly or + // by x += y: each place it is set. + class Sets final : public Luau::AstVisitor + { + public: + explicit Sets(Luau::AstLocal* local) : mLocal(local) {} + + std::vector at; + + bool visit(Luau::AstStatAssign* node) override + { + for (Luau::AstExpr* var : node->vars) + { + set(var); + } + return true; + } + bool visit(Luau::AstStatCompoundAssign* node) override + { + set(node->var); + return true; + } + + private: + void set(Luau::AstExpr* var) + { + if (const auto* local = var->as(); local && local->local == mLocal) + { + at.push_back(var->location); + } + } + + Luau::AstLocal* mLocal; + }; + // Every place each global is named, in the order of the text, and // whether the script names _G, through which any global may be read. class Globals final : public Luau::AstVisitor @@ -443,6 +486,69 @@ namespace } }; + // Every global the script sets, by name: where it is first set, and + // whether it is ever set outside every function -- at the top, or in a + // block of the top's. + class GlobalSets final : public Luau::AstVisitor + { + public: + struct Set + { + Luau::Location first; + bool atTop = false; + }; + boost::unordered_flat_map sets; + + bool visit(Luau::AstStatAssign* node) override + { + for (Luau::AstExpr* var : node->vars) + { + took(var); + } + return true; + } + bool visit(Luau::AstStatCompoundAssign* node) override + { + took(node->var); + return true; + } + // function f() at the top makes f there; one inside a function is + // FunctionInScope's to say. + bool visit(Luau::AstStatFunction* node) override + { + if (mDepth == 0) + { + took(node->name); + } + return true; + } + // Inside a function, however deep in the top's blocks it stands. + bool visit(Luau::AstExprFunction* node) override + { + ++mDepth; + node->body->visit(this); + --mDepth; + return false; + } + + private: + void took(Luau::AstExpr* var) + { + const auto* global = var->as(); + if (!global) + { + return; + } + const auto [it, added] = sets.try_emplace(global->name.value, Set{ global->location, false }); + if (!added && global->location.begin < it->second.first.begin) + { + it->second.first = global->location; + } + it->second.atTop = it->second.atTop || mDepth == 0; + } + S32 mDepth = 0; + }; + // What a loop's body does to strings, not looking into the functions // it makes: each append, s ..= x or s = s .. x, what it appends and to // what; every read of each local; and whether it returns. @@ -602,11 +708,22 @@ namespace bool visit(Luau::AstStatBlock* node) override { - if (on("SlGlobalAssign")) + if (on("SlGlobalAssign") || on("SlGlobalFunction")) { for (Luau::AstStat* stat : node->body) { - node == mRoot ? topGlobal(stat) : nestedFunction(node, stat); + if (node == mRoot) + { + topGlobal(stat); + } + else if (on("SlGlobalAssign")) + { + nestedFunction(node, stat); + } + } + if (node == mRoot && on("SlGlobalAssign")) + { + globalsSetInFunctions(); } } if (on("SlAlmostSwapped")) @@ -1078,6 +1195,10 @@ namespace { zeroLoop(node); } + if (on("SlForIndexAssign")) + { + forIndexSet(node); + } mOften.push_back(node->body->location); emptyLoop(node->body, Luau::Location(node->location.begin, node->body->location.begin), Luau::Location(node->body->location.begin, node->location.end), "for"); @@ -1983,6 +2104,10 @@ namespace { std::vector made_here; const bool function = stat->is(); + if (!on(function ? "SlGlobalFunction" : "SlGlobalAssign")) + { + return; + } if (const auto* assign = stat->as()) { for (Luau::AstExpr* var : assign->vars) @@ -2022,7 +2147,7 @@ namespace } ALScriptProblem& said = function ? problem(stat->location, "LuauLintSlGlobalFunction", "function [1] makes a global. local function [1] is quicker for Luau to call", { names }, - "SlGlobalAssign", Severity::Note) + "SlGlobalFunction", Severity::Note) : problem(stat->location, "LuauLintSlGlobalAssign", "This makes [1] a global. A local is quicker for Luau to read", { names }, "SlGlobalAssign", Severity::Note); @@ -2059,6 +2184,66 @@ namespace } } + // A global the script first sets inside a function, never at its top, + // and names in more than one of its top's statements: a note, fixed + // by a local of the script's before the first of them, which every + // function there still shares. Where one function alone names it, + // Luau's own GlobalUsedAsLocal says so. + void globalsSetInFunctions() + { + if (mGlobals.viaG) + { + return; + } + GlobalSets sets; + const_cast(mRoot)->visit(&sets); + std::vector> ordered(sets.sets.begin(), sets.sets.end()); + std::sort(ordered.begin(), ordered.end(), [](const auto& a, const auto& b) { return a.second.first.begin < b.second.first.begin; }); + for (const auto& [name, set] : ordered) + { + if (set.atTop || mTopMade.contains(name) || mGiven.contains(name) || name == "_G") + { + continue; + } + const auto named_at = mGlobals.named.find(name); + if (named_at == mGlobals.named.end()) + { + continue; + } + // The statements of the top that name it, in order. + std::vector tops; + for (const Luau::Location& at : named_at->second) + { + for (Luau::AstStat* top : mRoot->body) + { + if (top->location.encloses(at)) + { + if (tops.empty() || tops.back() != top) + { + tops.push_back(top); + } + break; + } + } + } + if (tops.size() < 2) + { + continue; + } + ALScriptProblem& said = problem(set.first, "LuauLintSlGlobalInFunction", + "[1] is made a global inside a function. A local of the script's, declared before the first " + "function that names it, is shared the same and quicker for Luau to read", + { name }, "SlGlobalAssign", Severity::Note); + if (startsLine(tops.front()->location.begin)) + { + const std::string indent(tops.front()->location.begin.column, ' '); + offerTitled(said, "ScriptFixDeclareFirst", "Declare local [1] before it is first named", { name }, + { edit(Luau::Location(tops.front()->location.begin, tops.front()->location.begin), "local " + name + "\n" + indent) }, + false); + } + } + } + // function f() in a block of its own, which makes a global only when // the block runs: a local of the block's where the block alone names // it, after it. @@ -2085,6 +2270,26 @@ namespace } } + // --- SlForIndexAssign: a numeric for's variable set inside it ------- + + // LSL's for went on from whatever its variable was set to in the + // body -- i = start to go back, i++ to skip one. Luau's numeric for + // makes the variable afresh from its own count each time round, so + // what the body gives it lasts to the end of that time round only. + // No fix: what the loop should do instead is a while loop's to say. + void forIndexSet(Luau::AstStatFor* node) + { + Sets sets(node->var); + node->body->visit(&sets); + for (const Luau::Location& at : sets.at) + { + problem(at, "LuauLintSlForIndexAssign", + "[1] is set here, but the for gives [1] its own next value each time round, whatever it was set to: LSL's for went on " + "from it. A while loop does", + { std::string(node->var->name.value) }, "SlForIndexAssign"); + } + } + // --- SlParenCondition: LSL's brackets round a condition ------------- // A condition bracketed whole, as LSL's had to be: the brackets taken diff --git a/indra/alscript/alscriptweight.cpp b/indra/alscript/alscriptweight.cpp index b2d07dff5c..dd84552975 100644 --- a/indra/alscript/alscriptweight.cpp +++ b/indra/alscript/alscriptweight.cpp @@ -31,12 +31,17 @@ #include "alluauservice.h" #include "alsourcemap.h" +#include "Luau/Ast.h" #include "Luau/Bytecode.h" #include "Luau/BytecodeHeader.h" +#include "Luau/BytecodeUtils.h" #include "Luau/Compiler.h" #include "Luau/LSLCompiler.h" #include "Luau/ParseResult.h" +#include "Luau/Parser.h" +#include +#include #include #include #include @@ -196,18 +201,36 @@ namespace bool mOk = true; }; + // An entry of a prototype's constant table: what kind, what it takes + // up there, and the strings it names -- a string's own, an import's + // path, a table template's keys -- by their places in the string + // table, from 0. + struct Constant + { + uint8_t type = 0; + size_t bytes = 0; + std::vector strings; + // What it names by other constants of the same table: an import's + // path, a template's keys, each to be read as a string once all are. + std::vector constants; + }; + struct Proto { - size_t begin = 0; - size_t end = 0; - uint64_t lineDefined = 0; - uint64_t name = 0; // into the string table, from one - std::vector lines; // one-based, per code word; empty without line info + size_t begin = 0; + size_t end = 0; + uint64_t lineDefined = 0; + uint64_t name = 0; // into the string table, from one + std::vector lines; // one-based, per code word; empty without line info + std::vector code; + std::vector constants; }; struct Bytecode { std::vector strings; + // What each string takes up in the table: its length and itself. + std::vector stringBytes; size_t stringsBegin = 0; size_t stringsEnd = 0; std::vector protos; @@ -227,12 +250,14 @@ namespace const uint64_t count = in.varint(); for (uint64_t i = 0; i < count && in.ok(); ++i) { + const size_t entry = in.at(); const uint64_t length = in.varint(); const size_t from = in.at(); in.skip(length); if (in.ok()) { out.strings.emplace_back(data.substr(from, static_cast(length))); + out.stringBytes.push_back(in.at() - entry); } } out.stringsEnd = in.at(); @@ -261,11 +286,17 @@ namespace } } const uint64_t sizecode = in.varint(); - in.skip(sizecode * 4); + for (uint64_t j = 0; j < sizecode && in.ok(); ++j) + { + p.code.push_back(static_cast(in.int32())); + } const uint64_t sizek = in.varint(); for (uint64_t k = 0; k < sizek && in.ok(); ++k) { - switch (in.byte()) + Constant constant; + const size_t entry = in.at(); + constant.type = in.byte(); + switch (constant.type) { case LBC_CONSTANT_NIL: break; @@ -282,28 +313,50 @@ namespace in.skip(32); break; case LBC_CONSTANT_STRING: + // From one; nought is no string. + if (const uint64_t id = in.varint(); id > 0) + { + constant.strings.push_back(id - 1); + } + break; case LBC_CONSTANT_CLOSURE: in.varint(); break; case LBC_CONSTANT_IMPORT: - in.skip(4); + { + // Up to three of the table's strings, ten bits each, + // their count in the top two. + const uint32_t id = static_cast(in.int32()); + const uint32_t names = id >> 30; + for (uint32_t j = 0; j < names; ++j) + { + constant.constants.push_back((id >> (20 - 10 * j)) & 1023); + } break; + } case LBC_CONSTANT_TABLE: { const uint64_t keys = in.varint(); for (uint64_t j = 0; j < keys && in.ok(); ++j) { - in.varint(); + constant.constants.push_back(in.varint()); } break; } case LBC_CONSTANT_TABLE_WITH_CONSTANTS: { + // Each key, and the constant its value is where it + // has one (-1 where not): no instruction names that + // value, the template carrying it. const uint64_t keys = in.varint(); for (uint64_t j = 0; j < keys && in.ok(); ++j) { - in.varint(); - in.skip(4); + constant.constants.push_back(in.varint()); + const int32_t value = in.int32(); + if (value >= 0) + { + constant.constants.push_back(static_cast(value)); + } } break; } @@ -324,6 +377,8 @@ namespace default: return false; } + constant.bytes = in.at() - entry; + p.constants.push_back(std::move(constant)); } const uint64_t children = in.varint(); for (uint64_t c = 0; c < children && in.ok(); ++c) @@ -410,6 +465,97 @@ namespace p.bytes = bytes; return p; } + + // A string the script keeps that weighs this much or more is a part of + // its own -- a key, a table of names, a notecard's worth of text -- each + // target's bytes for it taken out of what held them, so that no byte is + // counted twice. + constexpr size_t HEAVY_CONSTANT = 256; + + // A heavy string as a row names it: the start of its text, quoted, on + // one line. + std::string constantName(std::string_view text) + { + constexpr size_t SHOWN = 32; + // Cut never inside a character: where one is cut, before it. + size_t cut = std::min(text.size(), SHOWN); + while (cut > 0 && cut < text.size() && (static_cast(text[cut]) & 0xC0) == 0x80) + { + --cut; + } + std::string out = "\""; + for (size_t i = 0; i < cut; ++i) + { + const char c = text[i]; + out += c == '\n' ? std::string("\\n") : c == '\t' ? std::string("\\t") : std::string(1, c); + } + return out + (cut < text.size() ? "\xE2\x80\xA6\"" : "\""); + } + + ALScriptWeight::Part heavyConstant(std::string_view text, size_t bytes, S32 line) + { + ALScriptWeight::Part p = part(ALScriptWeight::Part::Kind::Constant, constantName(text), bytes); + if (line >= 0) + { + p.line = line; + p.column = 0; + p.endLine = line; + } + return p; + } + + // The constants an instruction names, by their places in its + // prototype's table: where each opcode keeps the index, as Bytecode.h + // documents it. + template + void constantsOf(uint32_t insn, uint32_t aux, Use use) + { + switch (static_cast(LUAU_INSN_OP(insn))) + { + case LOP_LOADK: + case LOP_GETIMPORT: + case LOP_DUPTABLE: + case LOP_DUPCLOSURE: + use(static_cast(LUAU_INSN_D(insn))); + break; + case LOP_LOADKX: + case LOP_GETGLOBAL: + case LOP_SETGLOBAL: + case LOP_GETTABLEKS: + case LOP_SETTABLEKS: + case LOP_NAMECALL: + case LOP_FASTCALL2K: + case LOP_NEWCLASSMEMBER: + use(aux); + break; + case LOP_JUMPXEQKN: + case LOP_JUMPXEQKS: + use(LUAU_INSN_AUX_KV(aux)); + break; + case LOP_GETUDATAKS: + case LOP_SETUDATAKS: + case LOP_NAMECALLUDATA: + use(LUAU_INSN_AUX_KV16(aux)); + break; + case LOP_ADDK: + case LOP_SUBK: + case LOP_MULK: + case LOP_DIVK: + case LOP_MODK: + case LOP_POWK: + case LOP_ANDK: + case LOP_ORK: + case LOP_IDIVK: + use(LUAU_INSN_C(insn)); + break; + case LOP_SUBRK: + case LOP_DIVRK: + use(LUAU_INSN_B(insn)); + break; + default: + break; + } + } } namespace @@ -421,6 +567,12 @@ namespace // a function, `_e/` a handler of the state so numbered -- // and where each is in the script. void nameLSLParts(ALScriptWeight& weight, std::string_view source); + // SLua's prototypes where the script has them, from `function` to its + // `end`, and named as the script means them where the compiler gives + // no name or not that one: a handler LLEvents is given, by its event -- + // and its state's, in a table of a state's handlers as the assistant + // writes them -- and what LLTimers calls, by how it is set going. + void nameSLuaParts(ALScriptWeight& weight, std::string_view source); } namespace ALScriptWeigh @@ -447,6 +599,10 @@ namespace ALScriptWeigh return weight; } weighAsset(weight, asset); + if (weight.compiled) + { + nameSLuaParts(weight, source); + } return weight; } @@ -463,9 +619,14 @@ namespace ALScriptWeigh } AL_SCRIPT_ENGINE_HELD; std::string asset; + std::string lined; try { asset = compileLSLAssetOrThrow(std::string(source), 0); + // Again with each instruction's line, which the server's asset + // has not: what each line comes to is read off this one, the + // rest off the server's. + lined = compileLSLAssetOrThrow(std::string(source), 0, true); } catch (const Luau::ParseErrors& e) { @@ -478,6 +639,28 @@ namespace ALScriptWeigh return weight; } weighAsset(weight, asset); + if (weight.compiled && !lined.empty()) + { + ALScriptWeight by_line; + weighAsset(by_line, lined); + weight.lines = std::move(by_line.lines); + // The heavy strings likewise, which only lines place: out of the + // table of strings, which is the same in both. + const size_t strings = static_cast(std::find_if(weight.parts.begin(), weight.parts.end(), + [](const ALScriptWeight::Part& one) { + return one.kind == ALScriptWeight::Part::Kind::Constant && one.name == "strings"; + }) - + weight.parts.begin()); + for (ALScriptWeight::Part& one : by_line.parts) + { + if (one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings" && strings < weight.parts.size() && + one.bytes <= weight.parts[strings].bytes) + { + weight.parts[strings].bytes -= one.bytes; + weight.parts.push_back(std::move(one)); + } + } + } nameLSLParts(weight, source); return weight; } @@ -505,8 +688,11 @@ namespace return; } weight.compiled = true; + const size_t strings_part = weight.parts.size(); weight.parts.push_back(part(ALScriptWeight::Part::Kind::Constant, "strings", read.stringsEnd - read.stringsBegin)); std::map by_line; + // The first line, from one, that names each string; nought for none. + std::vector string_at(read.strings.size(), 0); for (size_t i = 0; i < read.protos.size(); ++i) { const Proto& p = read.protos[i]; @@ -531,6 +717,54 @@ namespace last = std::max(last, line); } } + // Each constant of the prototype the first line of it that + // names it, and each string the first line of the script. + if (!p.lines.empty()) + { + std::vector constant_at(p.constants.size(), 0); + for (size_t pc = 0; pc < p.code.size();) + { + const uint32_t insn = p.code[pc]; + const uint32_t aux = pc + 1 < p.code.size() ? p.code[pc + 1] : 0; + const S32 line = pc < p.lines.size() ? p.lines[pc] : 0; + constantsOf(insn, aux, [&](uint32_t k) { + if (line > 0 && k < constant_at.size() && (constant_at[k] == 0 || line < constant_at[k])) + { + constant_at[k] = line; + } + }); + pc += static_cast(std::max(1, Luau::getOpLength(static_cast(LUAU_INSN_OP(insn))))); + } + // What an import's path and a template's keys and values + // name, with the constant that names them: no instruction + // does. + for (size_t k = 0; k < p.constants.size(); ++k) + { + for (const uint64_t other : p.constants[k].constants) + { + if (constant_at[k] > 0 && other < constant_at.size() && (constant_at[other] == 0 || constant_at[k] < constant_at[other])) + { + constant_at[other] = constant_at[k]; + } + } + } + for (size_t k = 0; k < p.constants.size(); ++k) + { + const S32 line = constant_at[k]; + if (line <= 0) + { + continue; + } + by_line[line - 1] += p.constants[k].bytes; + for (const uint64_t s : p.constants[k].strings) + { + if (s < string_at.size() && (string_at[s] == 0 || line < string_at[s])) + { + string_at[s] = line; + } + } + } + } if (!top && first > 0) { one.line = first - 1; @@ -539,6 +773,29 @@ namespace } weight.parts.push_back(std::move(one)); } + // The table of strings is a part; each heavy string in it a part of + // its own, at the first line that names it, and no longer the + // table's. The functions' names are the table's. + boost::unordered_flat_set names; + for (const Proto& p : read.protos) + { + if (p.name > 0) + { + names.insert(static_cast(p.name - 1)); + } + } + for (size_t s = 0; s < string_at.size(); ++s) + { + if (string_at[s] > 0 && s < read.stringBytes.size()) + { + by_line[string_at[s] - 1] += read.stringBytes[s]; + if (read.stringBytes[s] >= HEAVY_CONSTANT && !names.contains(s) && read.stringBytes[s] <= weight.parts[strings_part].bytes) + { + weight.parts[strings_part].bytes -= read.stringBytes[s]; + weight.parts.push_back(heavyConstant(read.strings[s], read.stringBytes[s], string_at[s] - 1)); + } + } + } for (const auto& [line, bytes] : by_line) { weight.lines.push_back({ line, bytes }); @@ -546,6 +803,184 @@ namespace } } +namespace +{ + // Every function of an SLua script, where it is, and what the script + // means it as where that is more than the compiler's name for it. + class SLuaFunctions final : public Luau::AstVisitor + { + public: + struct Meant + { + std::string name; + std::string within; + bool handler = false; + }; + std::vector all; + boost::unordered_flat_map meant; + + bool visit(Luau::AstExprFunction* node) override + { + all.push_back(node); + return true; + } + + // LLEvents:on("touch_start", function ...), LLEvents:once, and what + // LLTimers is given to call. + bool visit(Luau::AstExprCall* node) override + { + const auto* method = node->func->as(); + const auto* object = method ? method->expr->as() : nullptr; + if (!object || node->args.size == 0) + { + return true; + } + const std::string_view on = object->name.value; + const std::string_view what = method->index.value; + const auto* callback = node->args.data[node->args.size - 1]->as(); + if (!callback) + { + return true; + } + if (on == "LLEvents" && (what == "on" || what == "once")) + { + if (const auto* event = node->args.data[0]->as()) + { + meant[callback] = Meant{ std::string(event->value.data, event->value.size), std::string(), true }; + } + } + else if (on == "LLTimers") + { + meant[callback] = Meant{ "LLTimers:" + std::string(what), std::string(), false }; + } + return true; + } + + // LLEvents.touch_start = function ..., and a state's handlers in a + // table: states.default = { touch_start = function ... }, or + // states["name"] = { ... }. + bool visit(Luau::AstStatAssign* node) override + { + for (size_t i = 0; i < node->vars.size && i < node->values.size; ++i) + { + Luau::AstExpr* var = node->vars.data[i]; + Luau::AstExpr* value = node->values.data[i]; + if (const auto* field = var->as()) + { + const auto* object = field->expr->as(); + if (const auto* handler = value->as(); handler && object && std::string_view(object->name.value) == "LLEvents") + { + meant[handler] = Meant{ field->index.value, std::string(), true }; + } + if (named(field->expr, "states")) + { + stateTable(field->index.value, value); + } + } + else if (const auto* index = var->as(); index && named(index->expr, "states")) + { + if (const auto* key = index->index->as()) + { + stateTable(std::string(key->value.data, key->value.size), value); + } + } + } + return true; + } + + private: + static bool named(Luau::AstExpr* expr, std::string_view name) + { + if (const auto* local = expr->as()) + { + return std::string_view(local->local->name.value) == name; + } + const auto* global = expr->as(); + return global && std::string_view(global->name.value) == name; + } + + void stateTable(const std::string& state, Luau::AstExpr* value) + { + const auto* table = value->as(); + if (!table) + { + return; + } + for (const Luau::AstExprTable::Item& item : table->items) + { + const auto* key = item.key ? item.key->as() : nullptr; + const auto* handler = item.value ? item.value->as() : nullptr; + if (key && handler) + { + meant[handler] = Meant{ std::string(key->value.data, key->value.size), state, true }; + } + } + } + }; + + void nameSLuaParts(ALScriptWeight& weight, std::string_view source) + { + Luau::Allocator allocator; + Luau::AstNameTable names(allocator); + Luau::ParseOptions options; + Luau::ParseResult parsed = Luau::Parser::parse(source.data(), source.size(), names, allocator, options); + if (!parsed.root) + { + return; + } + SLuaFunctions found; + parsed.root->visit(&found); + // Each line's functions in the order the compiler finishes them -- + // one inside another first -- which is the order they end; and each + // line's prototypes in the order the bytecode has them. + std::map> on_line; + for (const Luau::AstExprFunction* function : found.all) + { + on_line[static_cast(function->location.begin.line)].push_back(function); + } + for (auto& [line, functions] : on_line) + { + std::stable_sort(functions.begin(), functions.end(), + [](const Luau::AstExprFunction* a, const Luau::AstExprFunction* b) { return a->location.end < b->location.end; }); + } + std::map> parts_on; + for (ALScriptWeight::Part& part : weight.parts) + { + if (part.kind == ALScriptWeight::Part::Kind::Function && part.line >= 0) + { + parts_on[part.line].push_back(&part); + } + } + for (auto& [line, parts] : parts_on) + { + const auto functions = on_line.find(line); + // Only where the two agree on how many there are. + if (functions == on_line.end() || functions->second.size() != parts.size()) + { + continue; + } + for (size_t i = 0; i < parts.size(); ++i) + { + ALScriptWeight::Part& part = *parts[i]; + const Luau::AstExprFunction* function = functions->second[i]; + part.line = static_cast(function->location.begin.line); + part.column = static_cast(function->location.begin.column); + part.endLine = static_cast(function->location.end.line); + part.endColumn = static_cast(function->location.end.column); + if (const auto meant = found.meant.find(function); meant != found.meant.end()) + { + part.name = meant->second.name; + part.within = meant->second.within; + if (meant->second.handler) + { + part.kind = ALScriptWeight::Part::Kind::Handler; + } + } + } + } + } +} + // --- LSO: Tailslide's image ------------------------------------------------------------------ namespace @@ -653,12 +1088,26 @@ namespace explicit LinedLSO(Tailslide::LSOSymbolDataMap& symbols) : LSOBytecodeCompiler(symbols) {} LineMarks marks; + // Each heavy string written, as a part of its own at its line. + std::vector heavy; bool visitSpecific(Tailslide::LSLASTNode* node) override { - marks.enter(node, mCodeBS.size()); + const size_t before = mCodeBS.size(); + marks.enter(node, before); const bool descend = LSOBytecodeCompiler::visitSpecific(node); marks.leave(mCodeBS.size()); + // A string written out where it is used, in the code. + if (node->getNodeSubType() == Tailslide::NODE_CONSTANT_EXPRESSION && mCodeBS.size() >= before + HEAVY_CONSTANT) + { + Tailslide::LSLConstant* value = static_cast(node)->getConstantValue(); + if (value && value->getNodeSubType() == Tailslide::NODE_STRING_CONSTANT) + { + const Tailslide::YYLTYPE* at = node->getLoc(); + heavy.push_back(heavyConstant(static_cast(value)->getValue(), mCodeBS.size() - before, + at && at->first_line > 0 ? zeroBased(at->first_line) : -1)); + } + } return descend; } }; @@ -743,6 +1192,20 @@ namespace LinedLSO lined(_mSymData); node->visit(&lined); lined.marks.spread(lined.mCodeBS.size(), lines); + // Its heavy strings parts of their own after it, no longer its. + if (parts.empty()) + { + return; + } + const size_t holder = parts.size() - 1; + for (ALScriptWeight::Part& constant : lined.heavy) + { + if (constant.bytes <= parts[holder].bytes) + { + parts[holder].bytes -= constant.bytes; + parts.push_back(std::move(constant)); + } + } } static std::string nameOf(Tailslide::LSLASTNode* node) @@ -1008,6 +1471,8 @@ namespace ALScriptWeigh std::set strings; size_t shared = MONO_BASE_BYTES; ALScriptWeight::Part* method = nullptr; + // Heavy strings, parts of their own once every method is in. + std::vector heavy; ALScriptWeight::Part globals; globals.kind = ALScriptWeight::Part::Kind::Frame; globals.name = "globals"; @@ -1086,8 +1551,29 @@ namespace ALScriptWeigh const size_t cut = line.find(' '); const std::string op = line.substr(0, cut); const std::string rest = cut == std::string::npos ? std::string() : line.substr(cut + 1); - // A string is the first load's, whose line and method made it. - const size_t bytes = ilBytes(op, rest) + (op == "ldstr" && strings.insert(rest).second ? userStringBytes(rest) : 0); + // A string is the first load's, whose line and method made it; + // a heavy one a part of its own, at that line. + size_t bytes = ilBytes(op, rest); + if (op == "ldstr" && strings.insert(rest).second) + { + const size_t text = userStringBytes(rest); + if (text >= HEAVY_CONSTANT) + { + const size_t open = rest.find('"'); + const size_t close = rest.rfind('"'); + heavy.push_back(heavyConstant(open != std::string::npos && close > open ? std::string_view(rest).substr(open + 1, close - open - 1) + : std::string_view(rest), + text, source)); + if (source >= 0) + { + by_line[source] += text; + } + } + else + { + bytes += text; + } + } method->bytes += bytes; if (source >= 0) { @@ -1105,6 +1591,7 @@ namespace ALScriptWeigh } weight.parts.insert(weight.parts.begin(), globals); weight.parts.insert(weight.parts.begin(), part(ALScriptWeight::Part::Kind::Frame, "assembly", shared)); + weight.parts.insert(weight.parts.end(), heavy.begin(), heavy.end()); for (const ALScriptWeight::Part& one : weight.parts) { weight.total += one.bytes; diff --git a/indra/alscript/tests/allsltoslua_test.cpp b/indra/alscript/tests/allsltoslua_test.cpp index 822cf3d457..68543ba1ef 100644 --- a/indra/alscript/tests/allsltoslua_test.cpp +++ b/indra/alscript/tests/allsltoslua_test.cpp @@ -384,7 +384,7 @@ namespace tut " integer f; for (f = 0; f < 3; ++f) total += f;\n" " llOwnerSay((string)(total + f));\n" "} }\n"); - ensure("up to a length: " + r.text, has(r, "for a = 1, #l do\n total += l[a]")); + ensure("up to a length, walking its items: " + r.text, has(r, "for _, item in l do\n total += item")); ensure("down by a step: " + r.text, has(r, "for b = 10, 0, -2 do")); ensure("up to and with: " + r.text, has(r, "for c = 1, n do")); ensure("a counter set in its body keeps the while: " + r.text, has(r, "while d < n do")); @@ -550,12 +550,14 @@ namespace tut " for (k = 0; k < 2; ++k) gReport += \"y\";\n" " llOwnerSay(out + seen + gReport + (string)outParts);\n" "} }\n"); - ensure("the outer loop's table, a name of its own: " + r.text, has(r, "local outParts2 = {}\n for i = 1, #names do")); - ensure("the pieces put in: " + r.text, has(r, "table.insert(outParts2, names[i] .. row)")); + ensure("the outer loop's table, a name of its own: " + r.text, has(r, "local outParts2 = {}\n for _, item in names do")); + ensure("the pieces put in: " + r.text, has(r, "table.insert(outParts2, item .. row)")); ensure("joined after: " + r.text, has(r, " end\n out ..= table.concat(outParts2)")); ensure("the inner loop's, declared in the outer: " + r.text, - has(r, " local rowParts = {}\n for j = 0, 2 do\n table.insert(rowParts, tostring(j))") && - has(r, " row ..= table.concat(rowParts)")); + has(r, " local rowParts = {}\n for j = 0, 2 do\n table.insert(rowParts, tostring(j))")); + ensure("declared empty just before it: declared where it is joined: " + r.text, + has(r, " end\n local row = table.concat(rowParts)") && !has(r, "local row = \"\"")); + ensure("declared with words of its own: added to: " + r.text, has(r, "local out = \"Names: \"") && has(r, "out ..= table.concat(outParts2)")); ensure("read in its loop: noted: " + r.text, has(r, "seen ..= \"x\"") && has(r, "-- LSL: seen is built with .. in a loop")); ensure("a global: noted: " + r.text, has(r, "gReport ..= \"y\"") && has(r, "-- LSL: gReport is built with .. in a loop")); checksClean(r); @@ -564,8 +566,12 @@ namespace tut typed.types = true; const ALLSLToSLua::Result t = ALLSLToSLua::convert("default { state_entry() { string s; integer i; for (i = 0; i < 3; ++i) s += \"z\"; llOwnerSay(s); } }\n", typed); - ensure("typed: " + t.text, has(t, "local sParts: { string } = {}")); + ensure("typed: " + t.text, has(t, "local sParts: { string } = {}") && has(t, "local s: string = table.concat(sParts)")); checksClean(t); + + const ALLSLToSLua::Result read = convert("default { state_entry() { string t = \"\"; llOwnerSay(t); integer i; for (i = 0; i < 3; ++i) t += \"q\"; llOwnerSay(t); } }\n"); + ensure("read between: declared where it was: " + read.text, has(read, "local t = \"\"") && has(read, "t ..= table.concat(tParts)")); + checksClean(read); } template<> template<> @@ -872,7 +878,7 @@ namespace tut " integer n = 1;\n" " llOwnerSay(llList2String(gNames, 0) + llList2String(p, -1) + llList2String(keys, 1));\n" " llOwnerSay(llList2String(mixed, 2) + (string)llList2Integer(mixed, 0) + llList2String(gNames, n));\n" - " integer i; for (i = 0; i < llGetListLength(p); ++i) llOwnerSay(llList2String(p, i));\n" + " integer i; for (i = 0; i < llGetListLength(p); ++i) llOwnerSay(llList2String(p, i) + llGetSubString(\"abc\", i, i));\n" "} }\n"); ensure("from the start, empty past the end: " + r.text, has(r, "(gNames[1] or \"\")")); ensure("back from the end, through a function's list: " + r.text, has(r, "(p[#p] or \"\")")); @@ -880,7 +886,7 @@ namespace tut ensure("a float's text is not tostring's, and an index not known: llcompat: " + r.text, has(r, "llcompat.List2String(mixed, 2)") && has(r, "llcompat.List2String(gNames, n)")); ensure("mixed items for a number: llcompat: " + r.text, has(r, "llcompat.List2Integer(mixed, 0)")); - ensure("within its length, as it is, counting from 1: " + r.text, has(r, "for i = 1, #p do\n print(p[i])")); + ensure("within its length, as it is, counting from 1: " + r.text, has(r, "for i = 1, #p do\n print(p[i] .. ll.GetSubString(\"abc\", i, i))")); checksClean(r); } @@ -1008,4 +1014,101 @@ namespace tut ensure("read in the studio's words", linted(ALLSLToSLua::notesIn(said.text, close.words)) == 1); ensure("not in English's", linted(ALLSLToSLua::notesIn(said.text, nullptr)) == 0); } + + template<> template<> + void allsltoslua_object::test<33>() + { + set_test_name("a parameter the script only reads as a truth is a boolean, each call giving one; one read as a number, or given more than TRUE where it is asked about TRUE, stays a number; an event's stay the grid's numbers"); + const ALLSLToSLua::Result r = convert("say(integer loud, string text) { if (loud) llShout(0, text); else llSay(0, text); }\n" + "glow(integer on) { if (!on) return; llOwnerSay(\"glow\"); }\n" + "integer next(integer n) { return n + 1; }\n" + "integer exactly(integer f) { return f == TRUE; }\n" + "default {\n" + " touch_start(integer n) {\n" + " say(TRUE, \"hi\");\n" + " say(n > 1, \"many\");\n" + " say(n, \"some\");\n" + " glow(FALSE);\n" + " llOwnerSay((string)next(n));\n" + " exactly(1); exactly(5);\n" + " }\n" + " on_rez(integer p) { if (p) llOwnerSay(\"rezzed\"); }\n" + "}\n"); + ensure("read only as a truth: a boolean: " + r.text, has(r, "local function say(loud, text)") && has(r, "if loud then")); + ensure("each call giving one, a number as its truth: " + r.text, + has(r, "say(true, \"hi\")") && has(r, "say(n > 1, \"many\")") && has(r, "say(n ~= 0, \"some\")")); + ensure("not, as a truth: " + r.text, has(r, "if not on then") && has(r, "glow(false)")); + ensure("read as a number: kept one: " + r.text, has(r, "return n + 1") && has(r, "next_(n)")); + ensure("given 5 where asked about TRUE: kept a number: " + r.text, has(r, "f == 1") && has(r, "exactly(5)")); + ensure("an event's: the grid's number: " + r.text, has(r, "if p ~= 0 then")); + checksClean(r); + + ALLSLToSLua::Options typed; + typed.types = true; + const ALLSLToSLua::Result t = ALLSLToSLua::convert("say(integer loud, string text) { if (loud) llSay(0, text); }\n" + "default { state_entry() { say(TRUE, \"hi\"); } }\n", + typed); + ensure("typed: " + t.text, has(t, "local function say(loud: boolean, text: string)")); + checksClean(t); + } + + template<> template<> + void allsltoslua_object::test<34>() + { + set_test_name("a counting loop over a list's items, its counter read as nothing else, walks the items; from another start, or read otherwise, it counts"); + const ALLSLToSLua::Result r = convert("list gNames = [\"a\", \"b\"];\n" + "default { state_entry() {\n" + " list keys = [llGetOwner()];\n" + " integer i;\n" + " for (i = 0; i < llGetListLength(gNames); ++i) llOwnerSay(llList2String(gNames, i));\n" + " integer j;\n" + " for (j = 0; j < llGetListLength(gNames); ++j) {\n" + " integer k;\n" + " for (k = 0; k < llGetListLength(keys); ++k) llOwnerSay(llList2String(gNames, j) + llList2String(keys, k));\n" + " }\n" + " integer m;\n" + " for (m = 1; m < llGetListLength(gNames); ++m) llOwnerSay(llList2String(gNames, m));\n" + " integer n;\n" + " for (n = 0; n < llGetListLength(gNames); ++n) llOwnerSay(llList2String(gNames, n) + (string)n);\n" + "} }\n"); + ensure("walked: " + r.text, has(r, "for _, item in gNames do\n print(item)\n")); + ensure("one inside another, each its own name, a key's made text: " + r.text, + has(r, "for _, item2 in keys do\n print(item .. tostring(item2))")); + ensure("from the second: counted: " + r.text, has(r, "for m = 2, #gNames do\n print(gNames[m])")); + ensure("its counter read as a number too: counted: " + r.text, has(r, "for n = 0, #gNames - 1 do")); + checksClean(r); + + const ALLSLToSLua::Result named = convert("list gNames = [\"a\"];\n" + "integer _ = 2;\n" + "default { state_entry() {\n" + " integer i;\n" + " for (i = 0; i < llGetListLength(gNames); ++i) llOwnerSay(llList2String(gNames, i) + (string)_);\n" + "} }\n"); + ensure("an index that would hide the script's _ named afresh: " + named.text, has(named, "for _2, item in gNames do")); + checksClean(named); + } + + template<> template<> + void allsltoslua_object::test<35>() + { + set_test_name("strings built in loops: one built by two loops declared once, the second adding to it; one appended to another in the loop it builds read as it stands, not built"); + const ALLSLToSLua::Result r = convert("default { state_entry() {\n" + " string s;\n" + " integer i;\n" + " for (i = 0; i < 2; ++i) s += \"a\";\n" + " integer j;\n" + " for (j = 0; j < 2; ++j) s += \"b\";\n" + " llOwnerSay(s);\n" + " string line;\n" + " string all;\n" + " integer k;\n" + " for (k = 0; k < 3; ++k) { line += \"x\"; all += line; }\n" + " llOwnerSay(all);\n" + "} }\n"); + ensure("declared at the first loop's join: " + r.text, has(r, "local s = table.concat(sParts)")); + ensure("the second adds to it: " + r.text, has(r, "s ..= table.concat(sParts2)") && !has(r, "local s = table.concat(sParts2)")); + ensure("one read in its loop kept as it is: " + r.text, has(r, "local line = \"\"") && !has(r, "table.concat(lineParts)")); + ensure("the other built from it: " + r.text, has(r, "table.insert(allParts, line)") && has(r, "local all = table.concat(allParts)")); + checksClean(r); + } } diff --git a/indra/alscript/tests/alpreprocessor_test.cpp b/indra/alscript/tests/alpreprocessor_test.cpp index b7cad252c2..c62acac964 100644 --- a/indra/alscript/tests/alpreprocessor_test.cpp +++ b/indra/alscript/tests/alpreprocessor_test.cpp @@ -95,6 +95,72 @@ namespace tut return out; } + static std::string readFile(const std::filesystem::path& path) + { + llifstream in(path, std::ios::binary); + std::stringstream buffer; + buffer << in.rdbuf(); + return buffer.str(); + } + + // Every newline in a text made `ending`. + static std::string ended(const std::string& text, std::string_view ending) + { + if (ending == "\n") + { + return text; + } + std::string out; + out.reserve(text.size() + text.size() / 16); + for (char c : text) + { + if (c == '\n') + { + out += ending; + } + else + { + out += c; + } + } + return out; + } + + // A golden file under tests/preprocessor/ run as the golden test runs + // it -- its includes from include/, the transforms its first line asks + // for -- with its text and its includes' ending their lines in + // `ending`. + ALPreprocessor::Result runGolden(const std::filesystem::path& file, std::string_view ending) + { + namespace fs = std::filesystem; + const fs::path dir = file.parent_path(); + const std::string source = readFile(file); + const bool lua = file.extension() == ".luau"; + ALPreprocessor::Options o = options(lua); + o.fileName = file.filename().string(); + o.resolve = [dir, ending](const ALPreprocessor::Ask& ask, ALPreprocessor::Include& out) { + // A require names its module without the extension. + for (const char* ext : { "", ".luau", ".lsl" }) + { + const fs::path path = dir / "include" / (ask.name + ext); + if (fs::exists(path)) + { + out.text = ended(readFile(path), ending); + out.name = path.filename().string(); + out.path = out.name; + return ALPreprocessor::Found::Yes; + } + } + return ALPreprocessor::Found::No; + }; + // The first line may ask for the transforms. + const std::string first = source.substr(0, source.find('\n')); + o.switches = first.find("switches") != std::string::npos; + o.lazyLists = first.find("lazylists") != std::string::npos; + o.compress = first.find("compress") != std::string::npos; + return ALPreprocessor::run(ended(source, ending), o); + } + static std::string messages(const ALPreprocessor::Result& r) { std::string out; @@ -596,37 +662,7 @@ namespace tut } const std::string stem = entry.path().stem().string(); const fs::path expected = dir / (stem + "_expected" + entry.path().extension().string()); - const auto read = [](const fs::path& path) { - llifstream in(path, std::ios::binary); - std::stringstream buffer; - buffer << in.rdbuf(); - return buffer.str(); - }; - const std::string source = read(entry.path()); - const bool lua = entry.path().extension() == ".luau"; - ALPreprocessor::Options o = options(lua); - o.fileName = name; - o.resolve = [&](const ALPreprocessor::Ask& ask, ALPreprocessor::Include& out) { - // A require names its module without the extension. - for (const char* ext : { "", ".luau", ".lsl" }) - { - const fs::path path = dir / "include" / (ask.name + ext); - if (fs::exists(path)) - { - out.text = read(path); - out.name = path.filename().string(); - out.path = out.name; - return ALPreprocessor::Found::Yes; - } - } - return ALPreprocessor::Found::No; - }; - // The first line may ask for the transforms. - const std::string first = source.substr(0, source.find('\n')); - o.switches = first.find("switches") != std::string::npos; - o.lazyLists = first.find("lazylists") != std::string::npos; - o.compress = first.find("compress") != std::string::npos; - ALPreprocessor::Result r = ALPreprocessor::run(source, o); + ALPreprocessor::Result r = runGolden(entry.path(), "\n"); ensure_equals("problems in " + name, messages(r), std::string()); // With AL_PREPROCESSOR_WRITE_EXPECTED set, the run writes the // expected files instead of checking them: for a new golden @@ -639,7 +675,7 @@ namespace tut continue; } ensure("expected file for " + name, fs::exists(expected)); - ensure_equals("output of " + name, r.text, read(expected)); + ensure_equals("output of " + name, r.text, readFile(expected)); ++checked; } ensure("some golden files", checked > 0 || std::getenv("AL_PREPROCESSOR_WRITE_EXPECTED")); @@ -1587,4 +1623,108 @@ namespace tut const std::vector tokens = ALPreprocessor::tokenize("--#define A 1\n#a\n", true); ensure("the mark, then the directive's words", tokens.size() > 2 && tokens[0].text == "--#" && tokens[1].text == "define"); } + + template<> template<> + void alpreprocessor_object::test<42>() + { + set_test_name("SLua: a --#define goes on over a backslash at its line's end, before LF, CRLF or a lone CR, and past blanks with a warning; an include's CRLF lines read as LF"); + const std::string lf = "--#define SAY(x) \\\n" + " ll.OwnerSay(x) \\\n" + " print(x)\n" + "SAY(\"hi\")\n"; + ALPreprocessor::Result r = ALPreprocessor::run(lf, options(true)); + ensure_equals("nothing wrong", messages(r), std::string()); + const std::string joined = r.text; + const size_t used = joined.find("ll.OwnerSay(\"hi\")"); + ensure("the body on the line that uses it: " + joined, + used != std::string::npos && joined.find("print(\"hi\")") > used && joined.find('\n', used) > joined.find("print(\"hi\")") && + joined.find('\\') == std::string::npos); + const auto ended = [](const std::string& text, const char* end) { + std::string out; + for (char c : text) + { + out += c == '\n' ? std::string(end) : std::string(1, c); + } + return out; + }; + r = ALPreprocessor::run(ended(lf, "\r\n"), options(true)); + ensure_equals("CRLF said nothing", messages(r), std::string()); + ensure_equals("CRLF", r.text, joined); + r = ALPreprocessor::run(ended(lf, "\r"), options(true)); + ensure_equals("a lone CR", r.text, joined); + + r = ALPreprocessor::run("--#define SAY(x) \\ \n" + " ll.OwnerSay(x) \\\t\n" + " print(x)\n" + "SAY(\"hi\")\n", + options(true)); + ensure_equals("blanks after the backslash", r.text, joined); + ensure_equals("said of each", messages(r), + std::string("W 0: backslash and newline separated by space\nW 1: backslash and newline separated by space\n")); + + add("say.luau", "--#define SAY(x) \\\r\n print(x)\r\n"); + r = ALPreprocessor::run("--#include \"say.luau\"\nSAY(1)\n", options(true)); + ensure_equals("an include with CRLF said nothing", messages(r), std::string()); + ensure("and its macro goes on over its lines: " + r.text, r.text.find("print(1)") != std::string::npos); + } + + template<> template<> + void alpreprocessor_object::test<43>() + { + set_test_name("every golden file and its includes, their lines ended in CRLF or in a lone CR as a Windows checkout or an old Mac would, come out as their expected text, saying nothing"); + namespace fs = std::filesystem; + const fs::path dir = fsyspath(AL_ALSCRIPT_TEST_DIR) / "preprocessor"; + S32 checked = 0; + for (const fs::directory_entry& entry : fs::directory_iterator(dir)) + { + const std::string name = entry.path().filename().string(); + if (name.compare(0, 5, "test_") != 0 || name.find("_expected") != std::string::npos) + { + continue; + } + const fs::path expected = dir / (entry.path().stem().string() + "_expected" + entry.path().extension().string()); + const std::string want = readFile(expected); + for (const char* ending : { "\r\n", "\r" }) + { + const std::string said = ending[1] ? " with CRLF" : " with a lone CR"; + ALPreprocessor::Result r = runGolden(entry.path(), ending); + ensure_equals("problems in " + name + said, messages(r), std::string()); + ensure_equals("output of " + name + said, r.text, want); + } + ++checked; + } + ensure("some golden files", checked > 0); + } + + template<> template<> + void alpreprocessor_object::test<44>() + { + set_test_name("a script holds a directive where a line is one, outside an LSL block comment; why one is preprocessed: enveloped, then its directives, then the setting"); + typedef ALPreprocessor P; + ensure("#define", P::usesDirectives("#define X 1\ndefault { state_entry() { } }\n", false)); + ensure("one further down, indented", P::usesDirectives("integer x;\n #include \"lib.lsl\"\n", false)); + ensure("none: # only in a string", !P::usesDirectives("default { state_entry() { llSay(0, \"#define\"); } }\n", false)); + ensure("none: a line comment", !P::usesDirectives("// #define X 1\ndefault { }\n", false)); + ensure("none: in a block comment", !P::usesDirectives("/* notes\n#define X 1\n*/\ndefault { }\n", false)); + ensure("after a block comment closes", P::usesDirectives("/* notes */\n#define X 1\n", false)); + ensure("not opened by one in a string", P::usesDirectives("string s = \"/*\";\n#define X 1\n", false)); + ensure("SLua's --#", P::usesDirectives("--#define X 1\nprint(X)\n", true)); + ensure("none: Luau's length operator first on a line", !P::usesDirectives("local n =\n #items\n", true)); + ensure("none: an SLua comment", !P::usesDirectives("-- # a heading\nprint(1)\n", true)); + ensure("none: LSL's # in SLua's place", !P::usesDirectives("#define X 1\n", true)); + + bool asked = false; + const auto directives = [&asked](bool answer) { + return [&asked, answer]() { + asked = true; + return answer; + }; + }; + ensure("enveloped first, its text not read", P::wanted(true, directives(true), true) == P::Wanted::Enveloped && !asked); + ensure("then its directives, the setting on or off", + P::wanted(false, directives(true), true) == P::Wanted::Directives && P::wanted(false, directives(true), false) == P::Wanted::Directives); + ensure("then the setting", P::wanted(false, directives(false), true) == P::Wanted::Setting); + ensure("else not", P::wanted(false, directives(false), false) == P::Wanted::No); + ensure("by text", P::wanted("#define X 1\n", false, false, false) == P::Wanted::Directives); + } } diff --git a/indra/alscript/tests/alpreprocessorc11_test.cpp b/indra/alscript/tests/alpreprocessorc11_test.cpp index a6d5493f78..5815a7e1ed 100644 --- a/indra/alscript/tests/alpreprocessorc11_test.cpp +++ b/indra/alscript/tests/alpreprocessorc11_test.cpp @@ -946,4 +946,43 @@ namespace tut ensure_equals("taken", flat(r.text), std::string("one two three four")); } } + + template<> template<> + void alpreprocessorc11_object::test<37>() + { + set_test_name("5.1.1.2: a line ends at CRLF and at a lone CR as at LF, so a backslash before either joins; on a directive's line blanks between the backslash and the newline are let by with a warning, elsewhere they join nothing"); + const auto ended = [](std::string text, const char* end) { + std::string out; + for (char c : text) + { + out += c == '\n' ? std::string(end) : std::string(1, c); + } + return out; + }; + const std::string lf = "#define SAY(x) \\\n" + " llOwnerSay(x); \\\n" + " llSay(0, x)\n" + "default { state_entry() { SAY(\"hi\"); } }\n"; + const std::string joined = pp(lf); + ensure("the macro's lines are its body: " + joined, joined.find("PROBLEMS") == std::string::npos && joined.find("llSay") != std::string::npos && + joined.find('\\') == std::string::npos && joined.find("default") == 0); + ensure_equals("CRLF", pp(ended(lf, "\r\n")), joined); + ensure_equals("a lone CR", pp(ended(lf, "\r")), joined); + ensure_equals("the lines after keep their numbers", pp(ended("#define A 1 \\\n + 2\nA __LINE__\n", "\r\n")), std::string("1 + 2 3")); + + { + const ALPreprocessor::Result r = run("#define SAY(x) \\ \t\n" + " llOwnerSay(x); \\ \n" + " llSay(0, x)\n" + "default { state_entry() { SAY(\"hi\"); } }\n"); + ensure_equals("blanks after a directive's backslash", flat(r.text), joined); + ensure_equals("said of each", messages(r), + std::string("W 0: backslash and newline separated by space\nW 1: backslash and newline separated by space\n")); + } + { + const ALPreprocessor::Result r = run("// a note \\ \nint shown;\n"); + ensure("a note's backslash and a blank take no line: " + r.text, r.text.find("int shown;") != std::string::npos); + ensure_equals("nothing said", messages(r), std::string()); + } + } } diff --git a/indra/alscript/tests/alscriptlintpass_test.cpp b/indra/alscript/tests/alscriptlintpass_test.cpp index 5621f052d2..871771cc5f 100644 --- a/indra/alscript/tests/alscriptlintpass_test.cpp +++ b/indra/alscript/tests/alscriptlintpass_test.cpp @@ -301,30 +301,43 @@ namespace tut template<> template<> void object::test<5>() { - set_test_name("SlGlobalAssign: a global made at the top, once, a note; a function made global in a block, a warning; not a name the " - "script is given; each made local, in place or declared before it is first named, and not where _G is"); + set_test_name("SlGlobalAssign: a global made at the top, once, a note; a function made global in a block, a warning; a function " + "at the top SlGlobalFunction's, a note, off on its own; not a name the script is given; each made local, in place or " + "declared before it is first named, and not where _G is"); ensure("definitions: " + error, loaded); - const std::string said = found("count = 0\n" - "function bump() count += 1; total = count end\n" - "local function early() return later end\n" - "later = 5\n" - "print = print\n" - "local function outer()\n" - " function inner() return 1 end\n" - " function shared() return 2 end\n" - " return inner()\n" - "end\n" - "x, y = 1, 2\n" - "print(bump, early, outer, shared, total, x, y)\n" - "count = 1\n", - "SlGlobalAssign", ALScriptProblem::Severity::Warning); + const std::string script = "count = 0\n" + "function bump() count += 1; total = count end\n" + "local function early() return later end\n" + "later = 5\n" + "print = print\n" + "local function outer()\n" + " function inner() return 1 end\n" + " function shared() return 2 end\n" + " return inner()\n" + "end\n" + "x, y = 1, 2\n" + "print(bump, early, outer, shared, total, x, y)\n" + "count = 1\n"; + const std::string said = found(script, "SlGlobalAssign", ALScriptProblem::Severity::Warning); ensure_equals("each", said, std::string("0 LuauLintSlGlobalAssign|count (another severity)\n" - "1 LuauLintSlGlobalFunction|bump (another severity)\n" + "1 LuauLintSlGlobalInFunction|total (another severity)\n" "3 LuauLintSlGlobalAssign|later (another severity)\n" "6 LuauLintSlGlobalFunctionInScope|inner\n" "7 LuauLintSlGlobalFunctionInScope|shared\n" "10 LuauLintSlGlobalAssign|x, y (another severity)\n")); + ensure_equals("the function at the top", found(script, "SlGlobalFunction", ALScriptProblem::Severity::Note), + std::string("1 LuauLintSlGlobalFunction|bump\n")); + ensure_equals("off on its own: the rest still said", found("--!nolint SlGlobalFunction\n" + script, "SlGlobalAssign", ALScriptProblem::Severity::Warning), + std::string("1 LuauLintSlGlobalAssign|count (another severity)\n" + "2 LuauLintSlGlobalInFunction|total (another severity)\n" + "4 LuauLintSlGlobalAssign|later (another severity)\n" + "7 LuauLintSlGlobalFunctionInScope|inner\n" + "8 LuauLintSlGlobalFunctionInScope|shared\n" + "11 LuauLintSlGlobalAssign|x, y (another severity)\n")); + ensure("and it not", found("--!nolint SlGlobalFunction\n" + script, "SlGlobalFunction", ALScriptProblem::Severity::Note).empty()); + ensure("nor the function where SlGlobalAssign alone is off", + found("--!nolint SlGlobalAssign\n" + script, "SlGlobalFunction", ALScriptProblem::Severity::Note) == "2 LuauLintSlGlobalFunction|bump\n"); ensure_equals("in place", fixed("count = 0\nprint(count)\n", "LuauLintSlGlobalAssign", "Write it local count", false), std::string("local count = 0\nprint(count)\n")); ensure_equals("declared first", fixed("local function early() return later end\nlater = 5\nprint(early())\n", "LuauLintSlGlobalAssign", @@ -828,4 +841,73 @@ namespace tut note.key = "SluaNote"; ensure("a note", ALScriptLintPass::migration(note)); } + + template<> template<> + void object::test<22>() + { + set_test_name("SlForIndexAssign: a numeric for's variable set in its body, plainly or by +=, a warning at each, in a function it makes " + "too; not a local of the same name in it, not a generic for's, not a while's; no fix"); + ensure("definitions: " + error, loaded); + const std::string said = found("local list = {\"a\", \"a\", \"b\"}\n" + "local start = 1\n" + "for i = 1, #list - 1 do\n" + " if list[i] ~= list[start] then\n" + " start += 1\n" + " i = start\n" + " end\n" + " i += 1\n" + " local function later() i = 0 end\n" + " later()\n" + "end\n" + "for i = 1, 2 do\n" + " local i = 5\n" + " i = 6\n" + " print(i)\n" + "end\n" + "for k, v in list do k = 2; print(k, v) end\n" + "local n = 1\n" + "while n < 3 do n = n + 1 end\n", + "SlForIndexAssign", ALScriptProblem::Severity::Warning); + ensure_equals("each", said, + std::string("5 LuauLintSlForIndexAssign|i\n" + "7 LuauLintSlForIndexAssign|i\n" + "8 LuauLintSlForIndexAssign|i\n")); + unfixed("for i = 1, 3 do i = 2 end\n", "LuauLintSlForIndexAssign"); + } + + template<> template<> + void object::test<23>() + { + set_test_name("SlGlobalAssign: a global first set inside a function and named by more than one statement of the top, a note, " + "declared local before the first; one function alone naming it left to Luau's GlobalUsedAsLocal; one set at the top " + "said as before"); + ensure("definitions: " + error, loaded); + const std::string shared = "function init()\n" + " count = 0\n" + "end\n" + "LLEvents:on(\"touch_start\", function(detected)\n" + " count += 1\n" + " print(count)\n" + "end)\n" + "init()\n"; + ensure_equals("set in one, named in another", found(shared, "SlGlobalAssign", ALScriptProblem::Severity::Note), + std::string("1 LuauLintSlGlobalInFunction|count\n")); + ensure_equals("declared before the first that names it", fixed(shared, "LuauLintSlGlobalInFunction", "Declare local count before it is first named", false), + std::string("local count\n") + shared); + ensure("one function alone: Luau's", + found("LLEvents:on(\"touch_start\", function(detected)\n total = 1\n print(total)\nend)\n", "SlGlobalAssign", ALScriptProblem::Severity::Note) + .find("LuauLintSlGlobalInFunction") == std::string::npos); + ensure("set at the top too: said there, not here", + found("count = 0\nfunction bump() count += 1 end\nbump()\n", "SlGlobalAssign", ALScriptProblem::Severity::Note).find("LuauLintSlGlobalInFunction") == + std::string::npos); + ensure("not where _G is", + found(std::string("print(_G)\n") + shared, "SlGlobalAssign", ALScriptProblem::Severity::Note).find("LuauLintSlGlobalInFunction") == std::string::npos); + ensure("set at the top in a block of it: not in a function", + found("if true then\n x = 1\nend\nfunction f() x = 2 end\nf()\nprint(x)\n", "SlGlobalAssign", ALScriptProblem::Severity::Note) + .find("LuauLintSlGlobalInFunction") == std::string::npos); + ensure("made by function f() at the top, its own lint off: made there", + found("--!nolint SlGlobalFunction\nfunction onTouch() end\nfunction reset() onTouch = nil end\nreset()\nonTouch()\n", "SlGlobalAssign", + ALScriptProblem::Severity::Note) + .find("LuauLintSlGlobalInFunction") == std::string::npos); + } } diff --git a/indra/alscript/tests/alscriptweight_test.cpp b/indra/alscript/tests/alscriptweight_test.cpp index 783b372822..76a939c17d 100644 --- a/indra/alscript/tests/alscriptweight_test.cpp +++ b/indra/alscript/tests/alscriptweight_test.cpp @@ -177,7 +177,88 @@ namespace tut const ALScriptWeight light = ALScriptWeigh::slua("local s = \"x\"\nprint(s)\n"); ensure("both compiled", heavy.compiled && light.compiled); ensure("the string weighs", heavy.total >= light.total + 2000); - ensure("in the strings", named(heavy, "strings") && named(heavy, "strings")->bytes >= 2000); + // Heavy, it is a part of its own, and no longer the table of + // strings'. + const ALScriptWeight::Part* constant = nullptr; + for (const ALScriptWeight::Part& one : heavy.parts) + { + constant = one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings" ? &one : constant; + } + // At the line that loads it: a local never set again is folded into + // where it is used, print(s), as -O1 compiles it. + ensure("a part of its own:" + listed(heavy), constant && constant->bytes >= 2000 && constant->line == 1); + ensure("named by its start: " + (constant ? constant->name : std::string()), constant && constant->name.rfind("\"xxxx", 0) == 0); + ensure("not the strings' too", named(heavy, "strings") && named(heavy, "strings")->bytes < 100); + ensure("a light one stays the strings'", std::none_of(light.parts.begin(), light.parts.end(), [](const ALScriptWeight::Part& one) { + return one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings"; + })); + // And for the LSL targets: LSO's in its code, Mono's in its strings, + // LSL on Luau's in its table; each a part of its own, out of the + // handler that held it, the whole as it was. + const std::string lsl = "default { state_entry() { llOwnerSay(\"" + words + "\"); } }\n"; + for (const ALScriptWeight& one : { ALScriptWeigh::lso(lsl), ALScriptWeigh::mono(lsl), ALScriptWeigh::lslLuau(lsl) }) + { + const ALScriptWeight::Part* found = nullptr; + size_t sum = 0; + for (const ALScriptWeight::Part& part : one.parts) + { + found = part.kind == ALScriptWeight::Part::Kind::Constant && part.name != "strings" ? &part : found; + sum += part.bytes; + } + const std::string target = ALScriptWeight::nameOf(one.target); + ensure(target + ": a part of its own:" + listed(one), one.compiled && found && found->bytes >= 2000 && found->line == 0); + ensure(target + ": counted once", sum <= one.total); + } + // A table's values written in it, which no instruction loads -- the + // template carries them: heavy ones are parts of their own too, at + // the table's line. + const ALScriptWeight table = ALScriptWeigh::slua("local MSG = {\n welcome = \"" + std::string(300, 'w') + "\",\n help = \"" + + std::string(300, 'h') + "\",\n}\nprint(MSG.welcome, MSG.help)\n"); + size_t values = 0; + for (const ALScriptWeight::Part& one : table.parts) + { + values += one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings" && one.bytes >= 300 && one.line >= 0 && one.line <= 3; + } + ensure_equals("each value a part of its own, at the table:" + listed(table), values, size_t(2)); + // Named by whole characters: the cut, 32 bytes in, falls inside + // one, or just after one. + const auto whole = [](const std::string& name) { + for (size_t i = 0; i < name.size();) + { + const unsigned char lead = static_cast(name[i]); + const size_t length = lead < 0x80 ? 1 : (lead & 0xE0) == 0xC0 ? 2 : (lead & 0xF0) == 0xE0 ? 3 : (lead & 0xF8) == 0xF0 ? 4 : 0; + if (length == 0 || i + length > name.size()) + { + return false; + } + for (size_t k = 1; k < length; ++k) + { + if ((static_cast(name[i + k]) & 0xC0) != 0x80) + { + return false; + } + } + i += length; + } + return true; + }; + std::string cyrillic, cjk = "a"; + for (int i = 0; i < 200; ++i) + { + cyrillic += "\xD0\x96"; + cjk += "\xE3\x81\x82"; + } + for (const std::string& text : { cyrillic, cjk }) + { + const ALScriptWeight wide = ALScriptWeigh::slua("local s = \"" + text + "\"\nprint(s)\n"); + const ALScriptWeight::Part* found = nullptr; + for (const ALScriptWeight::Part& one : wide.parts) + { + found = one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings" ? &one : found; + } + ensure("a wide one named: " + listed(wide), found && whole(found->name) && found->name.find("\xE2\x80\xA6") != std::string::npos); + } + const ALScriptWeight broken = ALScriptWeigh::slua("local function (\n"); ensure("a script that does not compile", !broken.compiled && !broken.error.empty()); } @@ -303,7 +384,8 @@ namespace tut } // LSL on Luau: the fork's own compiler, the asset as the server makes - // it, the bytecode read back as SLua's is. + // it, the bytecode read back as SLua's is; and again with its lines, + // for what each comes to. template<> template<> void alscriptweight_object::test<6>() { @@ -335,6 +417,11 @@ namespace tut const ALScriptWeight::Part* touch = named(weight, "touch_start"); ensure("a handler in its state:" + listed(weight), touch && touch->within == "default" && touch->kind == ALScriptWeight::Part::Kind::Handler); ensure_equals("where it is", touch->line, 11); + // Its lines, read off the compiler's lined bytecode, which the + // server's is not: the total stays what the server charges. + ensure("each line weighed:" + lined(weight), at(weight, 9) > 0 && at(weight, 13) > 0 && at(weight, 3) > 0); + ensure("the handler's long line its own:" + lined(weight), at(weight, 13) > at(weight, 9)); + ensure("nothing past the script's lines:" + lined(weight), within(weight, 16, 1000) == 0); const ALScriptWeight broken = ALScriptWeigh::lslLuau("default { state_entry() { integer x = ; } }\n"); ensure("a script that does not compile", !broken.compiled && !broken.error.empty()); } @@ -493,4 +580,60 @@ namespace tut ensure("the second less than the string" + lined(two), two.total - one.total < 20); ensure("the string is the first line's" + lined(two), at(two, 4) > at(two, 5) + 80); } + + // SLua's handlers and callbacks are named as the script means them, a + // state's with its state, each part from its function to its end; and + // a line carries the strings and constants it is first to name. + template<> template<> + void alscriptweight_object::test<12>() + { + const std::string script = "local greeting = \"a sentence of some length, said to the owner as the script starts up\"\n" + "local function twice(n: number): number\n" + " return n * 2\n" + "end\n" + "local states = {}\n" + "states.default = {\n" + " state_entry = function()\n" + " ll.OwnerSay(greeting)\n" + " end,\n" + "}\n" + "states[\"other place\"] = { touch_start = function(events) print(twice(1)) end }\n" + "LLEvents:on(\"touch_start\", function(events)\n" + " print(\"touched\")\n" + "end)\n" + "LLTimers:every(1, function()\n" + " print(\"tick\")\n" + "end)\n"; + const ALScriptWeight weight = ALScriptWeigh::slua(script); + ensure("compiled: " + weight.error, weight.compiled); + const auto find = [&](const std::string& name, const std::string& within) -> const ALScriptWeight::Part* { + for (const ALScriptWeight::Part& part : weight.parts) + { + if (part.name == name && part.within == within) + { + return ∂ + } + } + return nullptr; + }; + const ALScriptWeight::Part* twice = find("twice", ""); + ensure("a local function, from its function to its end:" + listed(weight), twice && twice->line == 1 && twice->endLine == 3 && twice->endColumn == 3); + ensure("not a handler", twice->kind == ALScriptWeight::Part::Kind::Function); + const ALScriptWeight::Part* entry = find("state_entry", "default"); + ensure("a state's handler, with its state:" + listed(weight), entry && entry->kind == ALScriptWeight::Part::Kind::Handler); + ensure("from its function to its end", entry->line == 6 && entry->column == 18 && entry->endLine == 8); + ensure("a state named by a string:" + listed(weight), find("touch_start", "other place") != nullptr); + const ALScriptWeight::Part* touched = find("touch_start", ""); + ensure("LLEvents' handler by its event:" + listed(weight), touched && touched->kind == ALScriptWeight::Part::Kind::Handler && touched->line == 11); + const ALScriptWeight::Part* tick = find("LLTimers:every", ""); + ensure("what LLTimers calls, by how it is set going:" + listed(weight), tick && tick->kind == ALScriptWeight::Part::Kind::Function); + + // The greeting, a constant the compiler puts where it is used, is + // that line's; the strings every line names are counted on some + // line, so that the lines come to most of the whole. + ensure("the long string on the line that says it:" + lined(weight), at(weight, 7) > 80); + ensure("a short one on its own line:" + lined(weight), at(weight, 12) > 4 + std::string("touched").size()); + const size_t lines = within(weight, 0, 100); + ensure("most of the whole on some line: " + std::to_string(lines) + " of " + std::to_string(weight.total), lines * 3 > weight.total * 2); + } } diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE b/indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE new file mode 100644 index 0000000000..e28cfad8e2 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE @@ -0,0 +1,25 @@ +The files under include/NexiiLSL/ are from https://github.com/Martin-Pitt/NexiiLSL +at 3918b11edd3d0ba47ed4048e14f0daf4026f9b00, copied as they are, under the licence +below: every file of it that a directive is in, and time.lsl, which one includes. + +MIT License + +Copyright (c) 2026 Martin Pitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl new file mode 100644 index 0000000000..a97688aff1 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl @@ -0,0 +1,10 @@ +#define agentURI(agent) "secondlife:///app/agent/" + string(agent) + "/inspect" +#define agentAboutURI(agent) "secondlife:///app/agent/" + string(agent) + "/about" +#define regionURI(region) "secondlife:///app/region/" + llEscapeURL(region) +#define objectURI(object) "secondlife:///app/objectim/" + string(object) + "?name=" + llEscapeURL(llKey2Name(object)) + "&owner=" + (string)llGetOwnerKey(object) +#define fullObjectURI(object, name, owner) "secondlife:///app/objectim/" + string(object) + "?name=" + llEscapeURL(name) + "&owner=" + string(owner) +#define groupURI(group) "secondlife:///app/group/" + string(group) + "/inspect" +#define groupAboutURI(group) "secondlife:///app/group/" + string(group) + "/about" +#define experienceURI(experience) "secondlife:///app/experience/" + string(experience) + "/profile" +#define chatURI(channel, message) "secondlife:///app/chat/" + string(channel) + "/" + llEscapeURL(message) +#define wikiLink(link, label) "[" + link + " " + label + "]" diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl new file mode 100644 index 0000000000..733e0e4c2a --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl @@ -0,0 +1,880 @@ +#define DAMAGE_TYPE_MEDICAL 100 +#define DAMAGE_TYPE_REPAIR 101 +#define DAMAGE_TYPE_EXPLOSIVE 102 +#define DAMAGE_TYPE_CRUSHING 103 +#define DAMAGE_TYPE_ANTI_ARMOR 104 +#define DAMAGE_TYPE_SUFFOCATION 105 +#define DAMAGE_TYPE_REDEPLOY 106 + +// Returns name of the constant that identifies the damage type +string DamageTypeAsConstant(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "DAMAGE_TYPE_IMPACT"; + if(type == DAMAGE_TYPE_GENERIC) return "DAMAGE_TYPE_GENERIC"; + if(type == DAMAGE_TYPE_ACID) return "DAMAGE_TYPE_ACID"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "DAMAGE_TYPE_BLUDGEONING"; + if(type == DAMAGE_TYPE_COLD) return "DAMAGE_TYPE_COLD"; + if(type == DAMAGE_TYPE_ELECTRIC) return "DAMAGE_TYPE_ELECTRIC"; + if(type == DAMAGE_TYPE_FIRE) return "DAMAGE_TYPE_FIRE"; + if(type == DAMAGE_TYPE_FORCE) return "DAMAGE_TYPE_FORCE"; + if(type == DAMAGE_TYPE_NECROTIC) return "DAMAGE_TYPE_NECROTIC"; + if(type == DAMAGE_TYPE_PIERCING) return "DAMAGE_TYPE_PIERCING"; + if(type == DAMAGE_TYPE_POISON) return "DAMAGE_TYPE_POISON"; + if(type == DAMAGE_TYPE_PSYCHIC) return "DAMAGE_TYPE_PSYCHIC"; + if(type == DAMAGE_TYPE_RADIANT) return "DAMAGE_TYPE_RADIANT"; + if(type == DAMAGE_TYPE_SLASHING) return "DAMAGE_TYPE_SLASHING"; + if(type == DAMAGE_TYPE_SONIC) return "DAMAGE_TYPE_SONIC"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "DAMAGE_TYPE_EMOTIONAL"; + if(type == DAMAGE_TYPE_MEDICAL) return "DAMAGE_TYPE_MEDICAL"; + if(type == DAMAGE_TYPE_REPAIR) return "DAMAGE_TYPE_REPAIR"; + if(type == DAMAGE_TYPE_EXPLOSIVE) return "DAMAGE_TYPE_EXPLOSIVE"; + if(type == DAMAGE_TYPE_CRUSHING) return "DAMAGE_TYPE_CRUSHING"; + if(type == DAMAGE_TYPE_ANTI_ARMOR) return "DAMAGE_TYPE_ANTI_ARMOR"; + if(type == DAMAGE_TYPE_SUFFOCATION) return "DAMAGE_TYPE_SUFFOCATION"; + if(type == DAMAGE_TYPE_REDEPLOY) return "DAMAGE_TYPE_REDEPLOY"; + return (string)type; +} + +// Returns damage type as a noun for use in sentences to describe the damage type +string DamageTypeAsNoun(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impact"; + if(type == DAMAGE_TYPE_GENERIC) return "generic"; + if(type == DAMAGE_TYPE_ACID) return "acid"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoning"; + if(type == DAMAGE_TYPE_COLD) return "cold"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electric"; + if(type == DAMAGE_TYPE_FIRE) return "fire"; + if(type == DAMAGE_TYPE_FORCE) return "force"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotic"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "piercing"; + if(type == DAMAGE_TYPE_POISON) return "poison"; + if(type == DAMAGE_TYPE_PSYCHIC) return "psychic"; + if(type == DAMAGE_TYPE_RADIANT) return "radiant"; + if(type == DAMAGE_TYPE_SLASHING) return "slashing"; + if(type == DAMAGE_TYPE_SONIC) return "sonic"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "emotional"; + if(type == DAMAGE_TYPE_MEDICAL) return "medical"; + if(type == DAMAGE_TYPE_REPAIR) return "repair"; + if(type == DAMAGE_TYPE_EXPLOSIVE) return "explosive"; + if(type == DAMAGE_TYPE_CRUSHING) return "crushing"; + if(type == DAMAGE_TYPE_ANTI_ARMOR) return "anti-armor"; + if(type == DAMAGE_TYPE_SUFFOCATION) return "suffocation"; + if(type == DAMAGE_TYPE_REDEPLOY) return "redeploy"; + return "unknown"; +} + +// Returns damage type as a verb for use in sentences as how it was applied to a target +string DamageTypeAsVerb(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impacted"; + if(type == DAMAGE_TYPE_GENERIC) return "damaged"; + if(type == DAMAGE_TYPE_ACID) return "corroded"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoned"; + if(type == DAMAGE_TYPE_COLD) return "frozen"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electrocuted"; + if(type == DAMAGE_TYPE_FIRE) return "burnt"; + if(type == DAMAGE_TYPE_FORCE) return "smashed"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotised"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "pierced"; + if(type == DAMAGE_TYPE_POISON) return "poisoned"; + if(type == DAMAGE_TYPE_PSYCHIC) return "assaulted"; + if(type == DAMAGE_TYPE_RADIANT) return "raptured"; + if(type == DAMAGE_TYPE_SLASHING) return "slashed"; + if(type == DAMAGE_TYPE_SONIC) return "ruptured"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "traumatised"; + if(type == DAMAGE_TYPE_MEDICAL) return "healed"; + if(type == DAMAGE_TYPE_REPAIR) return "repaired"; + if(type == DAMAGE_TYPE_EXPLOSIVE) return "exploded"; + if(type == DAMAGE_TYPE_CRUSHING) return "crushed"; + if(type == DAMAGE_TYPE_ANTI_ARMOR) return "punched through"; + if(type == DAMAGE_TYPE_SUFFOCATION) return "suffocated"; + if(type == DAMAGE_TYPE_REDEPLOY) return "redeployed"; + return "damaged"; +} + +// Returns PRIM_TEXTURE params for rendering the damage type as an icon +list DamageTypeAsIcon(integer type) +{ + vector offset; integer variant = (integer)llFrand(32.0); + if(type == DAMAGE_TYPE_IMPACT) offset = ; + else if(type == DAMAGE_TYPE_GENERIC) offset = <4, 0, 0>; + else if(type == DAMAGE_TYPE_ACID) offset = <5, 0, 0>; + else if(type == DAMAGE_TYPE_BLUDGEONING) offset = <6, 0, 0>; + else if(type == DAMAGE_TYPE_COLD) offset = <7, 0, 0>; + else if(type == DAMAGE_TYPE_ELECTRIC) offset = <8, 0, 0>; + else if(type == DAMAGE_TYPE_FIRE) offset = <9, 0, 0>; + else if(type == DAMAGE_TYPE_FORCE) offset = <10, 0, 0>; + else if(type == DAMAGE_TYPE_NECROTIC) offset = <11, 0, 0>; + else if(type == DAMAGE_TYPE_PIERCING) offset = <12, 0, 0>; + else if(type == DAMAGE_TYPE_POISON) offset = <13, 0, 0>; + else if(type == DAMAGE_TYPE_PSYCHIC) offset = <14, 0, 0>; + else if(type == DAMAGE_TYPE_RADIANT) offset = <15, 0, 0>; + else if(type == DAMAGE_TYPE_SLASHING) offset = <0, 1, 0>; + else if(type == DAMAGE_TYPE_SONIC) offset = <1 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_EMOTIONAL) offset = <3, 1, 0>; + else if(type == DAMAGE_TYPE_MEDICAL) offset = <4 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_REPAIR) offset = <6 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_EXPLOSIVE) offset = <8, 1, 0>; + else if(type == DAMAGE_TYPE_CRUSHING) offset = <9, 1, 0>; + else if(type == DAMAGE_TYPE_ANTI_ARMOR) offset = <10, 1, 0>; + else if(type == DAMAGE_TYPE_SUFFOCATION) offset = <12, 1, 0>; + else if(type == DAMAGE_TYPE_REDEPLOY) offset = <8, 3, 0>; + + // These are not damage types but additional icons related to combat + else if(type == -100) offset = <0, 3, 0>; // Death + else if(type == -101) offset = <1, 3, 0>; // Enemy Death + else if(type == -102) offset = <2, 3, 0>; // Friendly Death + else if(type == -103) offset = <3, 3, 0>; // Object Death + else if(type == -104) offset = <4, 3, 0>; // Vehicle Death + else if(type == -105) offset = <5, 3, 0>; // Aircraft Death + else if(type == -106) offset = <6, 3, 0>; // Unconscious/Downed + else if(type == -107) offset = <7, 3, 0>; // Revive Requested + else if(type == -108) offset = <8, 3, 0>; // Being Revived + + /* + Potential other icons: + - Respawn, Redeploy + - Mech Death + - Shield Hit, Armor Hit, Critical Hit, EMP Hit + - Critical Hit, Enemy Critical Hit, Friendly Critical Hit + - Blocked Hit, Enemy Blocked Hit, Friendly Blocked Hit + - Missed Hit, Enemy Missed Hit, Friendly Missed Hit + - Civilian Down, Enemy Down, Friendly Down + - Kill Assist + - Headshot + - Target Locked, Target Lost + - Out of Ammo, Low Ammo, Reloading + - Overheated, Jammed, Hacked, Disarmed, Suppressed, Spotted, Invisible + - Invulnerable, Vulnerable + - Buffed, Debuffed + */ + + return [ + "2d053029-7baa-104d-fd8e-34b2112b4b78", + <128./2048., 128./512., 0>, + <(offset.x - 7.5) / 16, (1.5 - offset.y) / 4, 0>, + 0 + ]; +} + +string DamageTypeAsReason(integer type, key owner, key target) +{ + // Picks a uniformly random entry from a list. Avoids the modulo bias + // of llFrand(64.0) % N and means you never have to update counts when + // adding/removing lines. + #define PICK(l) llList2String(l, (integer)llFrand((float)llGetListLength(l))) + + // ============================================================ + // ATTACKER vs VICTIM (owner != target) + // ============================================================ + #define FRIENDLY_FIRE [\ + "OWNER confirmed TARGET as 'probably hostile'",\ + "OWNER eliminated TARGET with outstanding target identification skills",\ + "OWNER won the team's internal conflict against TARGET",\ + "OWNER revealed to TARGET that the IFF system was decorative",\ + "OWNER engaged the nearest available lifeform: TARGET",\ + "OWNER delivered allied fire to TARGET. Emphasis on allied",\ + "OWNER mistook TARGET for a tactical opportunity",\ + "OWNER killed TARGET with an aggressive interpretation of teamwork",\ + "OWNER secured a friendly kill on TARGET. Mission unclear",\ + "OWNER taught TARGET not to stand between a squadmate and bad decisions",\ + "OWNER demonstrated why positive identification matters. TARGET objected briefly",\ + "OWNER greeted TARGET with extremely friendly burst fire",\ + "OWNER's suppressive fire caught TARGET non-suppressed",\ + "OWNER showed TARGET a safety selector permanently set to optimism",\ + "OWNER found TARGET faster than the enemy did",\ + "OWNER introduced TARGET to friendly fire",\ + "OWNER secured an enemy-adjacent kill on TARGET",\ + "OWNER promoted TARGET to hostile",\ + "OWNER carried the enemy team by killing TARGET",\ + "OWNER won an internal faction dispute against TARGET",\ + "OWNER treated TARGET like hostile armor",\ + "OWNER killed TARGET with unwavering confidence and limited accuracy",\ + "OWNER's line of fire met TARGET head-on",\ + "OWNER killed TARGET after TARGET challenged basic firearm safety",\ + "OWNER defended their muzzle space from TARGET",\ + "OWNER shot TARGET, who appeared suddenly and unhelpfully",\ + "OWNER's burst fire awarded TARGET a prize for crossing it",\ + "OWNER eliminated TARGET, who selected poor timing as a lifestyle",\ + "OWNER hit TARGET with rounds clearly intended for somebody else",\ + "OWNER's bullets met TARGET halfway",\ + "OWNER solved a tactical problem that TARGET inserted themselves into",\ + "OWNER killed TARGET after TARGET performed unauthorized bullet interception",\ + "OWNER was already shooting exactly where TARGET peeked",\ + "OWNER dropped TARGET, who entered the doorway at maximum inconvenience",\ + "OWNER engaged the enemy while TARGET was hugging it",\ + "OWNER's suppressive fire was suppressed by TARGET's positioning",\ + "OWNER demonstrated why you don't cross the firing lane. TARGET volunteered",\ + "OWNER secured a friendly kill after TARGET sprinted through active gunfire",\ + "OWNER penalized TARGET for performing close-quarters teamwork incorrectly",\ + "OWNER shot TARGET, who materialized directly in the optic picture",\ + "OWNER failed a trigger discipline test administered by TARGET at point-blank range",\ + "OWNER accidentally fulfilled TARGET's request for covering fire",\ + "OWNER charged TARGET full price for using the firing lane as a shortcut",\ + "OWNER killed TARGET, who confused suppressive fire with navigational guidance",\ + "OWNER confirmed TARGET located the line of fire by standing in it",\ + "OWNER eliminated TARGET after TARGET ignored several visible warning signs",\ + "OWNER intervened in TARGET's attempt at advanced teamwork without situational awareness",\ + "OWNER shot TARGET, who committed aggressively to being in the way",\ + "OWNER's ballistic calculations gained an unexpected variable: TARGET",\ + "OWNER's aim was fine. TARGET's pathfinding was not",\ + "OWNER cleared TARGET out from between themselves and success",\ + "OWNER killed TARGET after TARGET made themselves tactically unavoidable"\ + ] + #define IMPACT [\ + "OWNER knocked TARGET into a wall",\ + "OWNER slammed TARGET into cover",\ + "OWNER sent TARGET crashing into terrain",\ + "OWNER introduced TARGET to the ground at speed",\ + "OWNER forced TARGET into a fatal collision",\ + "OWNER's fire cancelled TARGET's momentum permanently",\ + "OWNER knocked TARGET off balance and into solid concrete",\ + "OWNER pressured TARGET into pancaking against the environment",\ + "OWNER turned gravity against TARGET",\ + "OWNER arranged a high-speed argument between TARGET and physics. Physics won"\ + ] + #define GENERIC [\ + "OWNER killed TARGET",\ + "OWNER eliminated TARGET",\ + "OWNER took down TARGET",\ + "OWNER neutralized TARGET",\ + "OWNER removed TARGET from the battlefield",\ + "OWNER put TARGET down",\ + "OWNER dropped TARGET",\ + "OWNER defeated TARGET",\ + "OWNER finished TARGET",\ + "OWNER took TARGET out of the fight"\ + ] + #define ACID [\ + "OWNER dissolved TARGET with corrosives",\ + "OWNER melted TARGET with a chemical attack",\ + "OWNER splashed TARGET with lethal acid",\ + "OWNER delivered a corrosive payload directly to TARGET",\ + "OWNER reduced TARGET's gear to sludge",\ + "OWNER chemically erased TARGET",\ + "OWNER turned chemistry into a weapon against TARGET",\ + "OWNER's acid attack proved too much for TARGET",\ + "OWNER corroded TARGET beyond repair",\ + "OWNER liquefied TARGET"\ + ] + #define BLUDGEONING [\ + "OWNER beat TARGET down",\ + "OWNER crushed TARGET with blunt force",\ + "OWNER smashed TARGET into submission",\ + "OWNER landed a fatal blunt hit on TARGET",\ + "OWNER battered TARGET into the dirt",\ + "OWNER flattened TARGET",\ + "OWNER hammered TARGET",\ + "OWNER's heavy impact ended TARGET",\ + "OWNER delivered overwhelming blunt trauma to TARGET",\ + "OWNER broke TARGET with a single strike"\ + ] + #define COLD [\ + "OWNER froze TARGET solid",\ + "OWNER's cryogenic attack claimed TARGET",\ + "OWNER chilled TARGET beyond recovery",\ + "OWNER iced TARGET over",\ + "OWNER turned TARGET into a frozen casualty",\ + "OWNER subjected TARGET to lethal cold exposure",\ + "OWNER locked TARGET down with extreme cold",\ + "OWNER left TARGET frozen mid-fight",\ + "OWNER weaponized winter against TARGET",\ + "OWNER won the heat war against TARGET"\ + ] + #define ELECTRIC [\ + "OWNER electrocuted TARGET",\ + "OWNER shocked TARGET to death",\ + "OWNER overloaded TARGET with electricity",\ + "OWNER delivered lethal voltage to TARGET",\ + "OWNER lit TARGET up with electrical discharge",\ + "OWNER shut down TARGET's nervous system",\ + "OWNER short-circuited TARGET",\ + "OWNER applied a high-voltage solution to TARGET",\ + "OWNER fried TARGET",\ + "OWNER made TARGET spark out"\ + ] + #define FIRE [\ + "OWNER burned TARGET alive",\ + "OWNER incinerated TARGET",\ + "OWNER engulfed TARGET in flames",\ + "OWNER cooked TARGET with incendiaries",\ + "OWNER reduced TARGET to ashes",\ + "OWNER set TARGET on fire. Permanently",\ + "OWNER scorched TARGET off the battlefield",\ + "OWNER won the thermal exchange with TARGET",\ + "OWNER roasted TARGET",\ + "OWNER's flames consumed TARGET"\ + ] + #define FORCE [\ + "OWNER blasted TARGET apart",\ + "OWNER launched TARGET",\ + "OWNER overwhelmed TARGET with raw force",\ + "OWNER hit TARGET with a fatal shockwave",\ + "OWNER hit TARGET with devastating force",\ + "OWNER's impact proved unsurvivable for TARGET",\ + "OWNER violently displaced TARGET",\ + "OWNER freight-trained TARGET",\ + "OWNER crushed TARGET with concussive power",\ + "OWNER blew TARGET clean off their position"\ + ] + #define NECROTIC [\ + "OWNER drained the life from TARGET",\ + "OWNER withered TARGET away",\ + "OWNER consumed TARGET's vitality",\ + "OWNER accelerated TARGET's decay considerably",\ + "OWNER left TARGET biologically ruined",\ + "OWNER's lethal touch faded TARGET out",\ + "OWNER reduced TARGET to a husk",\ + "OWNER outlasted TARGET's life force",\ + "OWNER brought slow death to TARGET",\ + "OWNER's necrotic damage claimed TARGET"\ + ] + #define PIERCING [\ + "OWNER riddled TARGET with bullets",\ + "OWNER cut TARGET down with rifle fire",\ + "OWNER stitched TARGET with automatic fire",\ + "OWNER put a burst into TARGET",\ + "OWNER perforated TARGET with incoming rounds",\ + "OWNER drilled TARGET with gunfire",\ + "OWNER landed center-mass hits on TARGET",\ + "OWNER won the firefight against TARGET",\ + "OWNER filled TARGET with lead",\ + "OWNER ventilated TARGET"\ + ] + #define POISON [\ + "OWNER poisoned TARGET",\ + "OWNER's toxins claimed TARGET",\ + "OWNER contaminated TARGET fatally",\ + "OWNER chemically deteriorated TARGET",\ + "OWNER dosed TARGET with lethal poison",\ + "OWNER convinced TARGET's body to give up",\ + "OWNER weaponized toxins against TARGET",\ + "OWNER's poison ran its full course on TARGET",\ + "OWNER delivered a toxic end to TARGET",\ + "OWNER gave TARGET a fatal chemical exposure"\ + ] + #define PSYCHIC [\ + "OWNER shattered TARGET's mind",\ + "OWNER caused TARGET's complete mental collapse",\ + "OWNER overwhelmed TARGET psychologically",\ + "OWNER out-thought TARGET fatally",\ + "OWNER broke TARGET mentally",\ + "OWNER won the cognitive battle against TARGET",\ + "OWNER overloaded TARGET's mind",\ + "OWNER psychologically neutralized TARGET",\ + "OWNER destabilized TARGET completely",\ + "OWNER gave TARGET more than TARGET could process"\ + ] + #define RADIANT [\ + "OWNER overwhelmed TARGET with radiant energy",\ + "OWNER burned TARGET with radiation",\ + "OWNER blasted TARGET with destructive light",\ + "OWNER made TARGET glow. Briefly",\ + "OWNER irradiated TARGET fatally",\ + "OWNER's energy discharge claimed TARGET",\ + "OWNER seared TARGET with pure energy",\ + "OWNER outshone TARGET fatally",\ + "OWNER exposed TARGET to lethal radiation",\ + "OWNER annihilated TARGET with raw energy"\ + ] + #define SLASHING [\ + "OWNER cut TARGET down",\ + "OWNER carved TARGET apart",\ + "OWNER opened TARGET up",\ + "OWNER's blade met TARGET. Once was enough",\ + "OWNER sliced through TARGET",\ + "OWNER left TARGET with fatal cuts",\ + "OWNER shredded TARGET",\ + "OWNER tore TARGET apart",\ + "OWNER made short work of TARGET",\ + "OWNER showed TARGET the sharp end"\ + ] + #define SONIC [\ + "OWNER blasted TARGET with sonic force",\ + "OWNER collapsed TARGET's senses",\ + "OWNER overwhelmed TARGET with pressure waves",\ + "OWNER's sonic blast proved fatal for TARGET",\ + "OWNER deafened TARGET permanently",\ + "OWNER rocked TARGET with acoustic weaponry",\ + "OWNER weaponized sound against TARGET",\ + "OWNER won the volume war against TARGET",\ + "OWNER shattered TARGET with sonic energy",\ + "OWNER concussed TARGET with a sonic assault"\ + ] + #define EMOTIONAL [\ + "OWNER emotionally devastated TARGET",\ + "OWNER collapsed TARGET's morale entirely",\ + "OWNER broke TARGET's spirit",\ + "OWNER dealt critical emotional damage to TARGET",\ + "OWNER crushed TARGET's confidence",\ + "OWNER won the psychological war against TARGET",\ + "OWNER destroyed TARGET's morale",\ + "OWNER applied pressure TARGET never recovered from",\ + "OWNER left TARGET mentally defeated",\ + "OWNER made sure TARGET took the loss personally"\ + ] + #define MEDICAL [\ + "OWNER's treatment killed TARGET",\ + "OWNER's medical intervention proved fatal to TARGET",\ + "OWNER flatlined TARGET during treatment",\ + "OWNER gave TARGET one treatment too many",\ + "OWNER's battlefield medicine failed TARGET catastrophically",\ + "OWNER's bedside manner killed TARGET",\ + "OWNER healed TARGET into the afterlife",\ + "OWNER's medkit betrayed TARGET's trust",\ + "OWNER's treatment proved terminal for TARGET",\ + "OWNER medically neutralized TARGET"\ + ] + #define REPAIR [\ + "OWNER repaired TARGET into catastrophic failure",\ + "OWNER's maintenance proved unsurvivable for TARGET",\ + "OWNER's field repairs ended TARGET",\ + "OWNER performed rapid unscheduled disassembly on TARGET",\ + "OWNER pushed TARGET beyond operational limits",\ + "OWNER's repair attempt finished TARGET off",\ + "OWNER turned maintenance into a kill",\ + "OWNER's adjustments collapsed TARGET's systems",\ + "OWNER overclocked TARGET to destruction",\ + "OWNER rendered TARGET permanently nonfunctional"\ + ] + #define EXPLOSIVE [\ + "OWNER blew TARGET apart",\ + "OWNER caught TARGET in the blast radius",\ + "OWNER introduced TARGET to high explosives",\ + "OWNER fed TARGET a grenade",\ + "OWNER detonated TARGET",\ + "OWNER's explosives outran TARGET",\ + "OWNER erased TARGET with a blast",\ + "OWNER's detonation shredded TARGET",\ + "OWNER reduced TARGET to debris",\ + "OWNER's ordnance found TARGET standing too close"\ + ] + #define CRUSHING [\ + "OWNER crushed TARGET",\ + "OWNER flattened TARGET completely",\ + "OWNER compressed TARGET into failure",\ + "OWNER pinned and crushed TARGET",\ + "OWNER turned pressure into a weapon against TARGET",\ + "OWNER folded TARGET like field equipment",\ + "OWNER reduced TARGET to scrap",\ + "OWNER compacted TARGET",\ + "OWNER buried TARGET under overwhelming pressure",\ + "OWNER ran a compression test on TARGET. TARGET failed"\ + ] + #define ANTI_ARMOR [\ + "OWNER punched through TARGET's armor",\ + "OWNER penetrated TARGET with anti-armor fire",\ + "OWNER defeated TARGET with AP rounds",\ + "OWNER proved TARGET's armor was a suggestion",\ + "OWNER cracked TARGET open with anti-vehicle fire",\ + "OWNER's penetrators went straight through TARGET",\ + "OWNER turned TARGET's armor into a liability",\ + "OWNER gutted TARGET with heavy ordnance",\ + "OWNER landed a clean anti-armor kill on TARGET",\ + "OWNER won the armor check against TARGET"\ + ] + #define SUFFOCATION [\ + "OWNER deprived TARGET of oxygen",\ + "OWNER suffocated TARGET",\ + "OWNER left TARGET without breathable air",\ + "OWNER ran TARGET out of air",\ + "OWNER choked the life out of TARGET",\ + "OWNER removed breathing from TARGET's options",\ + "OWNER turned atmosphere into a privilege TARGET didn't have",\ + "OWNER revoked TARGET's oxygen access",\ + "OWNER asphyxiated TARGET",\ + "OWNER reminded TARGET that breathing was mandatory. Too late"\ + ] + + // ============================================================ + // SELF-INFLICTED (owner == target) — TARGET-only phrasing + // ============================================================ + #define IMPACT_SELF [\ + "TARGET punched the ground. The ground punched back",\ + "TARGET tested the structural integrity of a nearby wall",\ + "TARGET tested the structural integrity of a nearby floor",\ + "TARGET tested the structural integrity of a nearby ceiling",\ + "TARGET discovered the upper limit of their movement enhancer",\ + "TARGET achieved terminal mobility",\ + "TARGET mistook momentum for a survival strategy",\ + "TARGET lost a close-quarters engagement with the terrain",\ + "TARGET deployed directly into solid matter",\ + "TARGET accelerated confidently into disaster",\ + "TARGET's movement tech delivered exactly as advertised",\ + "TARGET learned that velocity has consequences",\ + "TARGET attempted advanced maneuvering without adult supervision",\ + "TARGET converted speed into an immediate medical issue",\ + "TARGET overshot the objective by several meters and one life",\ + "TARGET used tactical sprint offensively against themselves",\ + "TARGET was outplayed by basic map geometry",\ + "TARGET trusted the jump pack. The jump pack disagreed",\ + "TARGET discovered an undocumented interaction between boosters and concrete",\ + "TARGET briefly achieved flight before remembering they weren't aviation-capable",\ + "TARGET's landing request was denied by physics",\ + "TARGET found the map boundary emotionally and physically",\ + "TARGET turned mobility equipment into a kinetic weapon against TARGET",\ + "TARGET's combat stim demanded speed; the wall accepted payment",\ + "TARGET exceeded recommended operating velocity",\ + "TARGET weaponized their own momentum",\ + "TARGET slammed into cover with unnecessary enthusiasm",\ + "TARGET's parkour certification has been revoked",\ + "TARGET hit mach jesus and forgot the braking phase",\ + "TARGET went from combatant to projectile",\ + "TARGET speedran the respawn screen",\ + "TARGET demonstrated why boosters come with warning labels",\ + "TARGET challenged Newtonian mechanics and lost unanimously",\ + "TARGET's movement enhancer secured a confirmed kill",\ + "TARGET achieved excellent airtime and poor decision-making",\ + "TARGET discovered a new route directly into the ground",\ + "TARGET attempted vertical gameplay and received horizontal consequences",\ + "TARGET became one with nearby infrastructure"\ + ] + #define GENERIC_SELF [\ + "TARGET eliminated TARGET. Efficient",\ + "TARGET cut out the middleman and killed themselves",\ + "TARGET found the enemy. It was TARGET",\ + "TARGET secured a confirmed kill on TARGET",\ + "TARGET defeated their greatest opponent: TARGET",\ + "TARGET needed no assistance dying",\ + "TARGET took themselves out of the fight",\ + "TARGET saved the enemy some ammunition"\ + ] + #define ACID_SELF [\ + "TARGET dissolved in their own corrosives",\ + "TARGET learned that acid doesn't check IFF",\ + "TARGET became the test subject of their own chemistry experiment",\ + "TARGET stored the acid incorrectly: on themselves",\ + "TARGET handled corrosives with confidence instead of gloves",\ + "TARGET's chemical weapon chose violence locally",\ + "TARGET self-liquefied",\ + "TARGET reduced their own gear, and TARGET, to sludge"\ + ] + #define BLUDGEONING_SELF [\ + "TARGET beat themselves to the punch. Fatally",\ + "TARGET delivered blunt trauma to the nearest available skull: their own",\ + "TARGET lost a fistfight against their own equipment",\ + "TARGET swung hard and connected with TARGET",\ + "TARGET self-administered percussive maintenance",\ + "TARGET was somehow on both ends of the hammer",\ + "TARGET flattened the closest combatant: TARGET",\ + "TARGET hammered themselves into the dirt unassisted"\ + ] + #define COLD_SELF [\ + "TARGET froze themselves solid",\ + "TARGET stress-tested their own cryogenics from the inside",\ + "TARGET achieved self-refrigeration",\ + "TARGET forgot which end of the cryo weapon was the cold one",\ + "TARGET preserved themselves for future generations",\ + "TARGET lost the heat war against TARGET",\ + "TARGET turned themselves into a frozen casualty",\ + "TARGET weaponized winter against the only person in range: TARGET"\ + ] + #define ELECTRIC_SELF [\ + "TARGET electrocuted themselves",\ + "TARGET completed the circuit personally",\ + "TARGET discovered they were the path of least resistance",\ + "TARGET grounded themselves. Permanently",\ + "TARGET conducted a one-person electrical safety demonstration",\ + "TARGET's high-voltage solution solved TARGET",\ + "TARGET short-circuited their own nervous system",\ + "TARGET fried the nearest conductor: TARGET"\ + ] + #define FIRE_SELF [\ + "TARGET self-ignited",\ + "TARGET stood in their own fire and called it warmth",\ + "TARGET deployed incendiaries at extremely personal range",\ + "TARGET became their own thermal signature",\ + "TARGET played with fire and lost custody",\ + "TARGET achieved self-cremation ahead of schedule",\ + "TARGET lost the thermal exchange with TARGET",\ + "TARGET's flames showed no brand loyalty"\ + ] + #define FORCE_SELF [\ + "TARGET blasted themselves apart",\ + "TARGET stood at the business end of their own shockwave",\ + "TARGET applied devastating force in the wrong direction",\ + "TARGET freight-trained TARGET",\ + "TARGET violently displaced themselves",\ + "TARGET's raw power needed a target. TARGET volunteered",\ + "TARGET blew themselves clean off their own position",\ + "TARGET found their own impact unsurvivable"\ + ] + #define NECROTIC_SELF [\ + "TARGET drained their own life force",\ + "TARGET accelerated their own decay considerably",\ + "TARGET reduced themselves to a husk",\ + "TARGET's life force resigned without notice",\ + "TARGET consumed their own vitality. Bold strategy",\ + "TARGET brought slow death to the nearest lifeform: TARGET",\ + "TARGET faded themselves out",\ + "TARGET left TARGET biologically ruined"\ + ] + #define PIERCING_SELF [\ + "TARGET shot themselves",\ + "TARGET checked if the gun was loaded the hard way",\ + "TARGET caught a burst from a familiar rifle: their own",\ + "TARGET lost a firefight with zero other participants",\ + "TARGET demonstrated muzzle awareness by counterexample",\ + "TARGET filled themselves with lead",\ + "TARGET ventilated TARGET",\ + "TARGET landed center-mass hits on the wrong center mass"\ + ] + #define POISON_SELF [\ + "TARGET poisoned themselves",\ + "TARGET taste-tested their own toxins",\ + "TARGET confused the antidote with the dosage",\ + "TARGET's chemical weapon worked exactly once, on TARGET",\ + "TARGET self-administered a lethal dose",\ + "TARGET trusted their own labeling system incorrectly",\ + "TARGET's body gave up after consulting with TARGET",\ + "TARGET delivered a toxic end to TARGET"\ + ] + #define PSYCHIC_SELF [\ + "TARGET thought themselves to death",\ + "TARGET lost an argument inside their own head",\ + "TARGET's mind overloaded itself",\ + "TARGET overthought a survivable situation. Fatally",\ + "TARGET was psychologically neutralized by TARGET",\ + "TARGET won the cognitive battle and also lost it",\ + "TARGET shattered their own mind unassisted",\ + "TARGET gave TARGET more than TARGET could process"\ + ] + #define RADIANT_SELF [\ + "TARGET irradiated themselves",\ + "TARGET stood inside their own energy discharge",\ + "TARGET glowed with self-confidence. Then just glowed",\ + "TARGET ignored the safe operating distance of their own weapon",\ + "TARGET achieved self-illumination. Briefly",\ + "TARGET exposed themselves to lethal radiation. Their own",\ + "TARGET outshone TARGET fatally",\ + "TARGET annihilated the closest energy signature: TARGET"\ + ] + #define SLASHING_SELF [\ + "TARGET cut themselves down",\ + "TARGET lost a knife fight against TARGET",\ + "TARGET found the sharp end of their own blade",\ + "TARGET demonstrated blade safety by counterexample",\ + "TARGET sliced through the nearest combatant: TARGET",\ + "TARGET made short work of themselves",\ + "TARGET opened TARGET up. No assistance required",\ + "TARGET's blade showed no loyalty"\ + ] + #define SONIC_SELF [\ + "TARGET deafened themselves to death",\ + "TARGET stood inside their own pressure wave",\ + "TARGET lost the volume war against TARGET",\ + "TARGET turned the acoustic weapon up to eleven while holding it",\ + "TARGET concussed themselves with their own bass drop",\ + "TARGET's sonic blast didn't discriminate",\ + "TARGET collapsed their own senses",\ + "TARGET weaponized sound against the closest pair of ears: their own"\ + ] + #define EMOTIONAL_SELF [\ + "TARGET's morale collapsed under TARGET",\ + "TARGET lost the psychological war with themselves",\ + "TARGET took the loss personally. From themselves",\ + "TARGET crushed their own confidence fatally",\ + "TARGET broke their own spirit before the enemy could",\ + "TARGET was their own harshest critic. Lethally",\ + "TARGET applied pressure TARGET never recovered from",\ + "TARGET emotionally devastated TARGET"\ + ] + #define MEDICAL_SELF [\ + "TARGET self-medicated into the afterlife",\ + "TARGET's self-treatment proved terminal",\ + "TARGET read the medkit instructions posthumously",\ + "TARGET healed themselves to death",\ + "TARGET trusted their own medical credentials incorrectly",\ + "TARGET flatlined under their own care",\ + "TARGET gave TARGET one treatment too many",\ + "TARGET's bedside manner killed the only patient available: TARGET"\ + ] + #define REPAIR_SELF [\ + "TARGET repaired themselves into catastrophic failure",\ + "TARGET performed rapid unscheduled self-disassembly",\ + "TARGET overclocked themselves to destruction",\ + "TARGET voided their own warranty",\ + "TARGET's self-maintenance proved unsurvivable",\ + "TARGET pushed TARGET beyond operational limits",\ + "TARGET turned self-maintenance into a confirmed kill",\ + "TARGET rendered TARGET permanently nonfunctional"\ + ] + #define EXPLOSIVE_SELF [\ + "TARGET held the grenade slightly too long",\ + "TARGET stood in their own blast radius",\ + "TARGET cooked a grenade past the recommended doneness",\ + "TARGET became the epicenter",\ + "TARGET's ordnance arrived before TARGET left",\ + "TARGET reduced themselves to debris",\ + "TARGET couldn't outrun their own explosives",\ + "TARGET ate their own grenade. Chef's kiss"\ + ] + #define CRUSHING_SELF [\ + "TARGET crushed themselves",\ + "TARGET was flattened by their own equipment",\ + "TARGET ran a compression test on TARGET. Both sides failed",\ + "TARGET parked something heavy on top of themselves",\ + "TARGET folded under pressure of their own making",\ + "TARGET compacted themselves for easy storage",\ + "TARGET reduced TARGET to scrap",\ + "TARGET turned pressure into a weapon against the only one in range: TARGET"\ + ] + #define ANTI_ARMOR_SELF [\ + "TARGET penetrated their own armor",\ + "TARGET proved their own armor was a suggestion",\ + "TARGET tested AP rounds on the nearest armor: their own",\ + "TARGET turned their armor into a liability personally",\ + "TARGET cracked themselves open with anti-vehicle fire",\ + "TARGET won and lost the armor check simultaneously",\ + "TARGET's penetrators went straight through TARGET",\ + "TARGET gutted TARGET with heavy ordnance. Impressive angle"\ + ] + #define SUFFOCATION_SELF [\ + "TARGET forgot breathing was mandatory",\ + "TARGET ran out of air with no help from anyone",\ + "TARGET revoked their own oxygen access",\ + "TARGET held their breath competitively against themselves",\ + "TARGET treated oxygen as optional",\ + "TARGET asphyxiated unassisted",\ + "TARGET removed breathing from their own options",\ + "TARGET turned atmosphere into a privilege TARGET didn't have"\ + ] + #define REDEPLOY [\ + "TARGET redeployed to a new position",\ + "TARGET returned to the deployment queue",\ + "TARGET chose to redeploy",\ + "TARGET cycled back to spawn",\ + "TARGET withdrew for redeployment",\ + "TARGET repositioned via redeploy",\ + "TARGET abandoned the current position",\ + "TARGET respawned elsewhere",\ + "TARGET reset their tactical situation",\ + "TARGET redeployed out of the combat zone",\ + "TARGET took the express route back to spawn",\ + "TARGET performed a tactical respawn",\ + "TARGET sought better life choices via redeploy",\ + "TARGET changed their mind — and location",\ + "TARGET performed tactical disappearance",\ + "TARGET reconsidered their surroundings",\ + "TARGET pressed the redeploy button",\ + "TARGET found a more survivable ZIP code",\ + "TARGET left to spawn somewhere less explosive",\ + "TARGET took a strategic timeout"\ + ] + + string reason; + + // Redeploy is always a voluntary self-action, regardless of who is credited + if(type == DAMAGE_TYPE_REDEPLOY) + { + reason = PICK(REDEPLOY); + } + else if(owner == target) + { + // Self-inflicted: TARGET-only phrasing, no attacker credit + if(type == DAMAGE_TYPE_IMPACT) reason = PICK(IMPACT_SELF); + else if(type == DAMAGE_TYPE_GENERIC) reason = PICK(GENERIC_SELF); + else if(type == DAMAGE_TYPE_ACID) reason = PICK(ACID_SELF); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = PICK(BLUDGEONING_SELF); + else if(type == DAMAGE_TYPE_COLD) reason = PICK(COLD_SELF); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = PICK(ELECTRIC_SELF); + else if(type == DAMAGE_TYPE_FIRE) reason = PICK(FIRE_SELF); + else if(type == DAMAGE_TYPE_FORCE) reason = PICK(FORCE_SELF); + else if(type == DAMAGE_TYPE_NECROTIC) reason = PICK(NECROTIC_SELF); + else if(type == DAMAGE_TYPE_PIERCING) reason = PICK(PIERCING_SELF); + else if(type == DAMAGE_TYPE_POISON) reason = PICK(POISON_SELF); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = PICK(PSYCHIC_SELF); + else if(type == DAMAGE_TYPE_RADIANT) reason = PICK(RADIANT_SELF); + else if(type == DAMAGE_TYPE_SLASHING) reason = PICK(SLASHING_SELF); + else if(type == DAMAGE_TYPE_SONIC) reason = PICK(SONIC_SELF); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = PICK(EMOTIONAL_SELF); + else if(type == DAMAGE_TYPE_MEDICAL) reason = PICK(MEDICAL_SELF); + else if(type == DAMAGE_TYPE_REPAIR) reason = PICK(REPAIR_SELF); + else if(type == DAMAGE_TYPE_EXPLOSIVE) reason = PICK(EXPLOSIVE_SELF); + else if(type == DAMAGE_TYPE_CRUSHING) reason = PICK(CRUSHING_SELF); + else if(type == DAMAGE_TYPE_ANTI_ARMOR) reason = PICK(ANTI_ARMOR_SELF); + else if(type == DAMAGE_TYPE_SUFFOCATION) reason = PICK(SUFFOCATION_SELF); + else if(type == -100) reason = "TARGET team-killed the only teammate within reach: TARGET"; + else reason = "TARGET was killed by TARGET's own " + DamageTypeAsNoun(type) + " damage. Impressive"; + } + else + { + // Attacker vs victim: OWNER-first phrasing + if(type == DAMAGE_TYPE_IMPACT) reason = PICK(IMPACT); + else if(type == DAMAGE_TYPE_GENERIC) reason = PICK(GENERIC); + else if(type == DAMAGE_TYPE_ACID) reason = PICK(ACID); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = PICK(BLUDGEONING); + else if(type == DAMAGE_TYPE_COLD) reason = PICK(COLD); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = PICK(ELECTRIC); + else if(type == DAMAGE_TYPE_FIRE) reason = PICK(FIRE); + else if(type == DAMAGE_TYPE_FORCE) reason = PICK(FORCE); + else if(type == DAMAGE_TYPE_NECROTIC) reason = PICK(NECROTIC); + else if(type == DAMAGE_TYPE_PIERCING) reason = PICK(PIERCING); + else if(type == DAMAGE_TYPE_POISON) reason = PICK(POISON); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = PICK(PSYCHIC); + else if(type == DAMAGE_TYPE_RADIANT) reason = PICK(RADIANT); + else if(type == DAMAGE_TYPE_SLASHING) reason = PICK(SLASHING); + else if(type == DAMAGE_TYPE_SONIC) reason = PICK(SONIC); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = PICK(EMOTIONAL); + else if(type == DAMAGE_TYPE_MEDICAL) reason = PICK(MEDICAL); + else if(type == DAMAGE_TYPE_REPAIR) reason = PICK(REPAIR); + else if(type == DAMAGE_TYPE_EXPLOSIVE) reason = PICK(EXPLOSIVE); + else if(type == DAMAGE_TYPE_CRUSHING) reason = PICK(CRUSHING); + else if(type == DAMAGE_TYPE_ANTI_ARMOR) reason = PICK(ANTI_ARMOR); + else if(type == -100) reason = PICK(FRIENDLY_FIRE); + else if(type == DAMAGE_TYPE_SUFFOCATION) reason = PICK(SUFFOCATION); + else reason = "OWNER killed TARGET with " + DamageTypeAsNoun(type) + " damage"; + } + + reason = llReplaceSubString(reason, "TARGET", "secondlife:///app/agent/" + (string)target + "/inspect", 0); + reason = llReplaceSubString(reason, "OWNER", "secondlife:///app/agent/" + (string)owner + "/inspect", 0); + return reason; +} + + +// Applies anti-armor damage in Combat2, with compatibility fallback to LBA (Listen Based Armor) +DamageArmor(key target, float damage) +{ + // Has Combat2 health, apply ANTI_ARMOR type + if(llGetHealth(target) > 0) + { + llDamage(target, damage, DAMAGE_TYPE_ANTI_ARMOR); + // llOwnerSay("Applied " + (string)damage + " anti-armor damage to " + (string)target); + } + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = integer("0x" + llGetSubString(llMD5String(target, 0), 0, 3)); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + // llOwnerSay("Applied " + (string)damage + " LBA damage to " + (string)target); + } + } +} + +// Alternatively, if you have llDetectedType available, such as from a collision or a sensor, +// use that instead of llGetHealth as you can check for DAMAGEABLE which is more accurate +// as it signifies the object can actually process damage +DamageArmorDetected(integer index, float damage) +{ + key target = llDetectedKey(index); + integer detectedType = llDetectedType(index); + + // Can process damage, so apply ANTI_ARMOR type + if(detectedType & DAMAGEABLE) llDamage(target, damage, DAMAGE_TYPE_ANTI_ARMOR); + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = integer("0x" + llGetSubString(llMD5String(target, 0), 0, 3)); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + } + } +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl new file mode 100644 index 0000000000..561280ed6c --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl @@ -0,0 +1,62 @@ + +#define IK_OUT_OF_REACH -1 +#define IK_TOO_CLOSE -2 +#define IK_SUCCESS 1 + +// Simple inverse kinematics via trigonemetry +TrigIK( + vector position, // relative position, only uses x (forward/back) and z (up/down) + float joint0, // First joint length + float joint1, // Second joint length + vector axis, // Axis that the joint rotates on + integer complementary // Whether to use complementary angle on result +) { + position.y = 0; + float distance = llVecMag(position); + + // If we can't reach it, just extend out + if(distance > joint0 + joint1) + { + IKr = IK_OUT_OF_REACH; + IK1 = llEuler2Rot(-axis * llAtan2(position.z, position.x)); + IK2 = ZERO_ROTATION; + return; + } + + float distanceSq = distance * distance; + float joint0Sq = joint0 * joint0; + float joint1Sq = joint1 * joint1; + float x = (joint0Sq - joint1Sq + distanceSq) / (2 * joint0 * distance); + float y = (joint0Sq + joint1Sq - distanceSq) / (2 * joint0 * joint1); + float q0 = llAtan2(position.z, position.x); + + // Too close + if(x > 1 || x < -1 || y > 1 || y < -1) + { + IKr = IK_TOO_CLOSE; + IK1 = llEuler2Rot(-axis *( q0)); + IK2 = ZERO_ROTATION; + return; + } + + // There's two different ways to reach the same point + float q1; float q2; + if(complementary) + { + q1 = q0 - llAcos(x); + q2 = PI - llAcos(y); + } + else + { + q1 = q0 + llAcos(x); + q2 = PI + llAcos(y); + } + + IKr = IK_SUCCESS; + IK1 = llEuler2Rot(-axis * q1); + IK2 = llEuler2Rot(-axis * q2); +} +// Return values: +integer IK; // IK results; IK_OUT_OF_REACH, IK_TOO_CLOSE, IK_SUCCESS +rotation IK1; // First joint rotation +rotation IK2; // Second joint rotation diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl new file mode 100644 index 0000000000..a312d9de38 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl @@ -0,0 +1,199 @@ +//////////////////////////////////////////////////////////////////////////////////////////// +/// Linkset scanning functions +/// These functions return linkset numbers + +// Returns a list of linkset numbers from a prim name +list LinksByName(string needle) { + list needles; + integer link = 1; + integer prims = llGetNumberOfPrims(); + for(; link <= prims; ++link) if(llGetLinkName(link) == needle) needles += link; + return needles; +} + +// Returns a linkset number based on the name +integer LinkByName(string needle) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) if(llGetLinkName(prims) == needle) return prims; + return FALSE; +} + +// Converts a list of prim names to a list of linkset numbers +list LinksetList(list needles) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) { + integer pointer = llListFindList(needles, [llGetLinkName(prims)]); + if(~pointer) needles = llListReplaceList(needles, [prims], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate +#define LinksetScan(conditions) \ + integer link = llGetNumberOfPrims();\ + do {\ + string linkName = llGetLinkName(link);\ + conditions\ + } while(--link > 1); + +/* Usage: +LinksetScan( + if(linkName == "Foot") Foot = link; + else if(linkName == "Leg") Leg = link; + else if(linkName == "Torso") Torso = link; + else if(linkName == "Head") Head = link; +); +*/ + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// Remote Object Linkset scanning variants +/// These functions return keys instead of linkset numbers + +// Returns a list of linkset keys from a prim name +list ObjectLinksByName(key object, string needle) { + list needles; + integer link = 1; + integer prims = llGetObjectPrimCount(object); + for(; link <= prims; ++link) + { + key linkKey = llGetObjectLinkKey(object, link); + string linkName = llKey2Name(linkKey); + if(linkName == needle) needles += linkKey; + } + return needles; +} + +// Returns a linkset key based on the name +key ObjectLinkByName(key object, string needle) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) + { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + if(linkName == needle) return linkKey; + } + return NULL_KEY; +} + +// Converts a list of prim names to a list of linkset keys +list ObjectLinksetList(key object, list needles) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + integer pointer = llListFindList(needles, [linkName]); + if(~pointer) needles = llListReplaceList(needles, [linkKey], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate +#define ObjectLinksetScan(object, conditions) \ + integer link = llGetObjectPrimCount(object);\ + do {\ + key linkKey = llGetObjectLinkKey(object, link);\ + string linkName = llKey2Name(linkKey);\ + conditions\ + } while(--link > 1); + +/* Usage: +ObjectLinksetScan(object, + if(linkName == "Turret") ... + else if(linkName == "Barrel") ... +); +*/ + + +// Scans an object for a list of sitting avatars +list ObjectLinksetSittingAvatars(key object) { + list details = llGetObjectDetails(object, [OBJECT_PRIM_COUNT, OBJECT_SIT_COUNT]); + integer prims = llList2Integer(details, 0); + integer sitters = llList2Integer(details, 1); + + list agents; + integer index = prims + 1; + integer count = prims + sitters; + for(; index <= count; ++index) agents += llGetObjectLinkKey(object, index); + + return agents; +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// "Linkset Resources" use Linkset Data to store/cache an array of linkset numbers in a compact format +/// The usecase is typically for reusable prims/meshes in a linkset such as in a HUD or user interface +/// For example for dynamically rendering markers, text or for data visualisation + +// Sets up a Linkset Resource on the key kv, finding prims in the linkset whose name exactly matches pattern to put into the resource +LinksetResourceSetup(string kv, string pattern) { + string links; + integer iterator = llGetNumberOfPrims(); + while(iterator --> 0) + { + string name = llGetLinkName(1 + iterator); + if(name == pattern) links = llChar(iterator) + links; + } + llLinksetDataWrite(kv, links); +} + +// Pulls out a linkset number from the resource +integer LinksetResourceReserve(string kv) { + string links = llLinksetDataRead(kv); + if(links == "") return FALSE; + llLinksetDataWrite(kv, llDeleteSubString(links, 0, 0)); + return 1 + llOrd(links, 0); +} + +// Releases a linkset number back into the resource +LinksetResourceRelease(string kv, integer link) { + llLinksetDataWrite(kv, llChar(link - 1) + llLinksetDataRead(kv)); +} + +// Resets the prim params for a Linkset Resource, e.g. to reset a bunch of reusable prims to a default state out of sight +LinksetResourceReset(string kv, list reset) { + list params; + string links = llLinksetDataRead(kv); + integer iterator = llStringLength(links); + while(iterator --> 0) + { + params += [PRIM_LINK_TARGET, 1 + llOrd(links, iterator)] + reset; + if(llGetFreeMemory() < 1500) { llSetLinkPrimitiveParamsFast(0, params); params = []; } + } + if(params) llSetLinkPrimitiveParamsFast(0, params); +} + +integer LinksetResourceLookup(string kv, integer index) { + string links = llLinksetDataRead(kv); + if(links) return 1 + llOrd(links, index); + else return FALSE; +} + + + +// These two functions build upon linkset resources at a higher level, so that you can trade linkset numbers between resources +// For example to have one linkset resource of unused reusable prims, and another for reusable prims you are currently using to render in a UI + +// Takes out a linkset number from the "Pool" resource and puts it into the "Used" resource +// You can flip the kv's to do the reverse +integer LinksetResourceUse(string kvPool, string kvUsed) { + string links = llLinksetDataRead(kvPool); + if(links == "") return FALSE; + llLinksetDataWrite(kvPool, llDeleteSubString(links, 0, 0)); + integer link = 1 + llOrd(links, 0); + llLinksetDataWrite(kvUsed, llChar(link - 1) + llLinksetDataRead(kvUsed)); + return link; +} + +// Frees up all the linkset numbers by moving all from the "Used" resource and puts them back into "Pool" resource +LinksetResourceFreeAll(string kvPool, string kvUsed) { + llLinksetDataWrite(kvPool, llLinksetDataRead(kvUsed) + llLinksetDataRead(kvPool)); + llLinksetDataWrite(kvUsed, ""); +} + +// TODO: A way to move a specific linkset number from a "Used" resource into the "Pool" resource in one function +// LinksetResourceFree(string kvPool, string kvUsed, integer link) {...} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl new file mode 100644 index 0000000000..0a1bce70cc --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl @@ -0,0 +1,104 @@ +// Implementation of a public Remote Linkset Data storage system, similar in practice to llLinksetDataRead but on a remote object +// Gotcha is that this works entirely through prim params (names, descriptions and/or hover text to store data) +// This does mean that this it is mostly suitable for a server object to hold the remote linkset data + +// PRIM_NAME -- 63 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_DESC -- 127 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_TEXT -- 254 bytes, full UTF-8 range -- best for storing the raw values chunked across multiple prims +// -- Warning: Any PRIM_TEXT with unicode in range 0x1-0x1F via llChar corrupts the object from being rezzed from inventory +// Storage capacity is dependent on the number of prims in the linkset +// Values are divided by 254 bytes per child prim's PRIM_TEXT, but it is not guaranteed to utilise capacity fully +// as data is reserved in 254 byte chunks for each `name:value` pair, so e.g. if you have a linkset of 61 prims for example (where 1 prim is the root): +// - 60x`name:value` pairs if value is <= 254 bytes, or +// - 30x`name:value` pairs if values are up to 254*2 bytes. + +string RemoteLinksetDataRead(key object, string name) +{ + if(object == llGetKey() || object == NULL_KEY) return llLinksetDataRead("Remote." + name); + + // TODO: Lookup table on root prim? + integer link = 1; + @remoteReadSearch; + key prim = llGetObjectLinkKey(object, ++link); + if(prim == NULL_KEY) return ""; + string primName = llKey2Name(prim); + if(primName != name) jump remoteReadSearch; + + string value; + @remoteReadLoop; + value += (string)llGetObjectDetails(prim, [OBJECT_TEXT]); + prim = llGetObjectLinkKey(object, ++link); + primName = llKey2Name(prim); + if(primName == name) jump remoteReadLoop; + + return value; +} + +integer RemoteLinksetDataWrite(key object, string name, string value) +{ + if(object != llGetKey() && object != NULL_KEY) return 6; // Currently only allow writing from own object to keep the implementation straightforward + + // TODO: Check many prims we have left available for writing (any prims free at all + chunk/prim for full value) + + // We mirror the data locally for rebuilds + integer result = llLinksetDataWrite("Remote." + name, value); + if(result) return result; + + RebuildRemoteLinksetData(); + + return LINKSETDATA_OK; +} + + + +// INTERNAL -- Rebuild entire remote dataset, this is not efficient but it keeps the implementation robust, avoiding defragmentation issues +RebuildRemoteLinksetData() +{ + list params; + + list names = llLinksetDataFindKeys("^Remote\\.", 0, 0); + integer link = 2; + #ifdef RLSD_LINK_START + link = RLSD_LINK_START; + #endif + integer index; + integer count = llGetListLength(names); + vector lastColor; + for(; index < count; ++index) + { + string name = llList2String(names, index); + string value = llLinksetDataRead(name); + name = llDeleteSubString(name, 0, 6); + + vector color = (vector)llLinksetDataRead("RemoteDebug." + name); + while(color == ZERO_VECTOR || color * lastColor > 0.8) llLinksetDataWrite("RemoteDebug." + name, string(color = llVecNorm())); + lastColor = color; + + integer chunks = llCeil(llStringLength(value) / 254.); + while(chunks --> 0) + { + string chunk = llGetSubString(value, 0, 253); + value = llDeleteSubString(value, 0, 253); + params += [ + PRIM_LINK_TARGET, link++, + PRIM_NAME, name, + PRIM_TEXT, chunk, <0, 0, 0>, 0 + + #ifdef RLSD_DISPLAY + , PRIM_COLOR, 0, color, 1 + #endif + ]; + } + } + + // TODO: Lookup table on root prim? + + llSetLinkPrimitiveParamsFast(LINK_ALL_CHILDREN, [ + PRIM_NAME, "-", + PRIM_TEXT, "", <0, 0, 0>, 0 + + #ifdef RLSD_DISPLAY + , PRIM_COLOR, 0, <0, 0, 0>, 1 + #endif + ] + params); +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl new file mode 100644 index 0000000000..591343baba --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl @@ -0,0 +1,251 @@ +#include "NexiiLSL/app-uri.lsl" + +float start; +float end; +integer hasQueue; +integer hasLargeQueue; +list tracked; + +integer lastCount; +integer tick; + +checkAttachments() +{ + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + string animation = llGetAnimation(agent); + string legacyName = llKey2Name(agent); // Ghosted avatars have an empty string + string displayName = llGetDisplayName(agent); // May not always be non-empty string? + string userName = llGetUsername(agent); // May not always be non-empty string? + list attachments = llGetAttachedList(agent); + + if(animation == "" + || animation == "Init" + || legacyName == "" + || displayName == "" + || userName == "" + || llGetListLength(attachments) == 0) + jump continue; // Do not consider agent if entering/exiting region, logging, teleporting or ghosted + + list prevAttachments = llJson2List(llLinksetDataRead(agent + "_attachments")); + list attached; + list detached; + + // Attached + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + integer pointer = llListFindList(prevAttachments, [attachment]); + if(pointer == -1) + { + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + attached += attachment; + } + } + + // Detached + index = llGetListLength(prevAttachments); + while(index --> 0) + { + string attachment = llList2String(prevAttachments, index); + integer pointer = llListFindList(attachments, [(key)attachment]); + if(pointer == -1) + { + // llLinksetDataDelete(agent + "_" + attachment); + detached += attachment; + } + } + + // Cache + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + + + //if(hasQueue || hasLargeQueue) + //{ + //string message; + integer a = llGetListLength(attached); + integer d = llGetListLength(detached); + /*if(a && d) message = agentAboutURI(agent) + " attached " + string(a) + " and detached " + string(d) + " items"; + else if(a) message = agentAboutURI(agent) + " attached " + string(a) + " items"; + else if(d) message = agentAboutURI(agent) + " detached " + string(d) + " items";*/ + + if(a) tracked += agentAboutURI(agent); + + /*while(a --> 0) + { + string attachment = llList2String(attached, a); + message += "\n+ " + objectURI(attachment); + }*/ + + while(d --> 0) + { + string attachment = llList2String(detached, d); + // message += "\n- " + llLinksetDataRead(attachment + "_name"); + llLinksetDataDelete(attachment + "_name"); + // llLinksetDataDelete(agent + "_" + attachment); + } + + //if(message) llOwnerSay(message); + //} + + @continue; + } +} + +announce(string message) { + string name = llGetObjectName(); + llSetObjectName(llGetRegionName()); + + //llSay(PUBLIC_CHANNEL, message); + + //* + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + key agent = llList2Key(agents, iterator); + llRegionSayTo(agent, PUBLIC_CHANNEL, message); + } + //*/ + + llSetObjectName(name); +} + + +default +{ + state_entry() + { + llSetText("", <1,1,1>, 1); + llLinksetDataDeleteFound("_(attachments|name)$", ""); + + list agents = llGetAgentList(AGENT_LIST_REGION, []); + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + integer iterator = lastCount = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + } + } + + llOwnerSay("Initialise Rez Queue Checker (Mem " + llGetSubString((string)(llGetFreeMemory() / 1024.0), 0, -6) + "KB / LSD " + llGetSubString((string)(llLinksetDataAvailable() / 1024.0), 0, -6) + "KB)"); + // llOwnerSay((string)); + + llSetTimerEvent(20/45.); + checkAttachments(); + } + + timer() + { + if(llGetTime() > 60*60*12) llResetScript(); + + ++tick; + + integer count = llGetRegionAgentCount(); + if(lastCount != count) + { + lastCount = count; + list lastAgents = llJson2List(llLinksetDataRead("agents")); + list agents = llGetAgentList(AGENT_LIST_REGION, []); + list added; + list removed; + while(count --> 0) + { + string agent = llList2String(agents, count); + integer pointer = llListFindList(lastAgents, [agent]); + if(pointer == -1) + { + added += agentAboutURI(agent); + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + } + } + + count = llGetListLength(lastAgents); + while(count --> 0) + { + string agent = llList2String(lastAgents, count); + integer pointer = llListFindList(agents, [(key)agent]); + if(pointer == -1) + { + removed += agentAboutURI(agent); + llLinksetDataDeleteFound("^" + agent, ""); + } + } + + //if(removed) llOwnerSay("Removed: " + llList2CSV(removed)); + //if(added) llOwnerSay("Added: " + llList2CSV(added)); + + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + } + + float now = llGetTime(); + float downloads = llGetSimStats(SIM_STAT_ASSET_DOWNLOADS); + float uploads = llGetSimStats(SIM_STAT_ASSET_UPLOADS); + + // if(downloads > 0.5 || uploads > 0.5) + if(downloads > 0.01 || uploads > 0.01) + { + if(!hasQueue) + { + //llOwnerSay("Downloads pending!"); + hasQueue = TRUE; + end = start = now; + } + + else if(hasQueue) + { + end = now; + if(downloads > 0.9 && /*now - end > 1.5 &&*/ !hasLargeQueue) + { + hasLargeQueue = TRUE; + tracked = []; + announce("Rez queue detected!"); + } + } + } + + else if(hasLargeQueue) + { + float delta = end - start; + start = end = 0; + checkAttachments(); + string message = "Rez queue ended after " + llGetSubString((string)delta, 0, -5) + "s"; + if(tracked) message += " due to " + llDumpList2String(tracked, ", "); + announce(message); + hasQueue = hasLargeQueue = FALSE; + tracked = []; + } + + else if(hasQueue) + { + float delta = end - start; + checkAttachments(); + //llOwnerSay("Pending over after " + llGetSubString((string)delta, 0, -5) + "s"); + hasQueue = FALSE; + } + + else if(!(tick % 45)) checkAttachments(); + } + + changed(integer change) + { + if(change & CHANGED_REGION) llResetScript(); + } + + attach(key avatar) + { + llResetScript(); + } +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl new file mode 100644 index 0000000000..d00f54cf11 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl @@ -0,0 +1,244 @@ +/* +The Magic Roundabout + +This script is designed to help with the dilemma of assigning one script in the region +as the main controller responsible for loading remote data such as from experience storage +or HTTP requests and then having to distribute any loaded data to other subordinate scripts + +Make sure the script is set to no modify so people can't peek at the private key used +-- that is the only piece of info that must truly be kept secret + +Because the script sends along a signature in the listener messages, it means you are +limited to 852 bytes max (1024 - 172) +*/ + +#include "NexiiLSL/time.lsl" + +#define CHANNEL_ROUNDABOUT -100 // replace with secret channel + +string PrivateKey = "-----BEGIN RSA PRIVATE KEY----- +replace with private key +-----END RSA PRIVATE KEY-----"; +string PublicKey = "-----BEGIN PUBLIC KEY----- +replace with public key +-----END PUBLIC KEY-----"; +float LastPingPong = -60.0; +float LastController = -60.0; + +key Controller = NULL_KEY; +list Subordinates; + +integer Tick; + +default +{ + state_entry() + { + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "init", ""); + llLinksetDataWrite("Roundabout", "init"); + state subordinate; + } +} + +state subordinate +{ + state_entry() + { + llListen(CHANNEL_ROUNDABOUT, "", "", ""); + llSetTimerEvent(10.0); + + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + payload); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + LastPingPong = llGetTime(); + + // Respond to ping with pong + text = "pong," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + } + + else if(message == "pong") + { + LastPingPong = llGetTime(); + + // Was controller, but might have been reset + if(identifier == Controller) Controller = NULL_KEY; + + // Add to subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer == -1) Subordinates += identifier; + } + + else if(message == "controller") + { + LastController = llGetTime(); + + // We just initialised and there is a controller out there + // Announce to other scripts we are subordinate and ready to request data + if(Controller == NULL_KEY) + { + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "subordinate", ""); + Controller = identifier; + } + + // New controller + else if(identifier != Controller) Controller = identifier; + + // Same controller + else if(identifier == Controller); + + // Remove from subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer != -1) Subordinates = llDeleteSubList(Subordinates, pointer, pointer); + } + + // Pass along any other messages from controller into linkset + else if(identifier == Controller) + { + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, text, identifier); + } + } + + timer() + { + ++Tick; + float time = llGetTime(); + + // We lost our controller + if(time - LastController > 60.0 || (Controller != NULL_KEY && llKey2Name(Controller) == "")) + { + // Promote oldest subordinate to controller + key subordinate = llGetKey(); + list checks = [subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME]))]; + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) != "") checks += [ + subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME])) + ]; + } + checks = llListSortStrided(checks, 2, 1, TRUE); + subordinate = llList2Key(checks, 0); + + // Promote ourselves + if(subordinate == llGetKey()) state controller; + + // Promote subodinate to controller + else Controller = subordinate; + } + + // Ping everyone + else if(time - LastPingPong > 30.0) + { + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + payload); + } + + // Garbage collection on list + if(!(Tick % 16)) + { + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) == "") + { + llOwnerSay("Lost subordinate " + (string)subordinate); + Subordinates = llDeleteSubList(Subordinates, iterator, iterator); + } + } + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == CHANNEL_ROUNDABOUT + 1 && Controller != NULL_KEY) + { + // Pass along messages from subordinate to controller + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSayTo(Controller, CHANNEL_ROUNDABOUT, signature + text); + } + } + + state_exit() + { + // We don't track subordinates outside this state + Subordinates = []; + } +} + + +state controller +{ + state_entry() + { + string text = "controller," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + + llLinksetDataWrite("Roundabout", "controller"); + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "controller", ""); + llListen(CHANNEL_ROUNDABOUT, "", "", ""); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + // Respond to ping with controller + text = "controller," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + } + else if(message == "pong"); // Ignore + else if(message == "controller") + { + // Huh? There was another controller? + LastController = llGetTime(); + Controller = identifier; + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "subordinate", ""); + state subordinate; + } + + // Pass along any other messages from subordinates into linkset + else + { + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, text, identifier); + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == CHANNEL_ROUNDABOUT + 1) + { + // Pass along messages from controller to subordinates + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + } + } +} \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl new file mode 100644 index 0000000000..0e0f5221c8 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl @@ -0,0 +1,632 @@ +/* + Synchronised Linkset Data + + This script synchronises specific linkset data keys across the region that match a prefix, per the config. + + On initialisation, rezzing or changing region it does a full reset/synchronisation with the full + tracked dataset (per scopes) of the network and then simply listens for changes as they propagate across the region. + + Each object can be configured to only sync specific prefixes to optimise bandwidth and processing. + + If you want to persist data across reboots (e.g. if syncing data across temp attach / intermittent objects), + make sure to establish a permanent server object that holds data. +*/ + +// SETTINGS BELOW CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +/// Security +// We use RSA signatures to verify secure communications +string privateKey = "-----REPLACE WITH RSA PRIVATE KEY-----"; +string publicKey = "-----REPLACE WITH RSA PUBLIC KEY-----"; +// We use a protected linkset data key to prevent spoofing due to extracted scripts +// You only need this when using this script in no-mod attachments, e.g. experience temp attach +string protectedName = "SYNC_LSD_PROTECTED"; +string protectedKey = "-----REPLACE WITH SECRET-----"; + +/// Scope prefixes of linkset data keys to synchronise +list scopes = [ + "Test_", + "Foo_", + "Bar_" +]; +// Corresponding listener channels for each scope above +list scopeChannels = [ + 204505, + 204506, + 204507 +]; + +// SETTINGS ABOVE CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Link Message Numbers +#define MESSAGE_SYNC_BOOT 1 +#define MESSAGE_SYNC_READY 2 + +// Event Types Enumeration +#define EVENT_TYPE 63 +#define EVENT_PING 1 +#define EVENT_PONG 2 +#define EVENT_SYNC_REQUEST 3 +#define EVENT_SYNC_RESPONSE 4 +#define EVENT_SYNC_COMPLETE 5 +#define EVENT_DATA 6 +#define EVENT_MESSAGE 7 +// Event Bit flags +#define EVENT_FRAGMENT 64 +#define EVENT_FINAL_FRAGMENT 128 + + +list candidates = [/* key object, string scope */]; +list pending = [/* integer action, string name */]; +list verified = [/* key object */]; +list buffer = [/* string name, string value */]; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +integer inScope(string name) { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) return TRUE; + } + return FALSE; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + +sendData(key target, integer channel, integer eventType, string name, string value) { + integer nameLength = llStringLength(name); + integer available = 1023 - (3 + nameLength); + integer total = getStringBytes(value); + integer isASCII = (total == llStringLength(value)); // Prefer llStringLength as its fastest + + // Fragment the update across multiple messages + if(total > available) + { + eventType = eventType | EVENT_FRAGMENT; + while(value) + { + string fragment = ""; + integer width = 0; + while(total > 0) + { + string part = llGetSubString(value, 0, 250); + integer bytes; + if(isASCII) bytes = llStringLength(part); + else bytes = getStringBytes(part); + + if(width + bytes <= available) + { + value = llDeleteSubString(value, 0, 250); + width += bytes; + total -= bytes; + fragment += part; + } + else jump breakFragment; + } + @breakFragment; + + if(value == "") eventType = eventType | EVENT_FINAL_FRAGMENT; + + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + fragment; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } + } + + // Direct message + else + { + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + value; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Initialise by pinging all and get latest dataset from network +default +{ + state_entry() + { + if(llLinksetDataReadProtected(protectedName, protectedKey) == "") + { + llDetachFromAvatar(); + llDie(); + return; + } + + llMessageLinked(LINK_SET, MESSAGE_SYNC_BOOT, "", ""); + + // Reset scoped data, setup listeners and ping everyone + integer iterator = llGetListLength(scopes); + string signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + integer signatureLength = llStringLength(signature); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + llLinksetDataDeleteFound("^" + prefix, ""); + integer channel = llList2Integer(scopeChannels, iterator); + llListen(channel, "", "", ""); + llRegionSay(channel, llChar(EVENT_PING) + llChar(signatureLength) + signature + prefix); + } + + // Timeout from pongs + llSetTimerEvent(0.5); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & EVENT_TYPE; + integer eventFlags = header & ~EVENT_TYPE; + + if(eventType == EVENT_PONG) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Don't add to verified list yet, check scope first + + // Refresh timeout + llSetTimerEvent(FALSE); + llSetTimerEvent(0.5); + + // Add to candidates if scope matches + integer isVerified = (llListFindList(verified, [identifier]) != -1); + list peerScope = llParseString2List(llGetSubString(message, 2 + signatureLength, -1), [llChar(1)], []); + integer peerIterator = llGetListLength(peerScope); + while(peerIterator --> 0) + { + string peerPrefix = llList2String(peerScope, peerIterator); + + // Check if each peer prefix matches any of our scopes + integer ownIterator = llGetListLength(scopes); + while(ownIterator --> 0) + { + string ownPrefix = llList2String(scopes, ownIterator); + + // Phase 1: Check if peer prefix is wider, shorter or exact match + // Is peer prefix wider? + if(llSubStringIndex(peerPrefix, ownPrefix) != 0) + { + // Is peer prefix shorter? + if(llSubStringIndex(ownPrefix, peerPrefix) != 0) + { + // Peer prefix doesnt match our scope to track, so skip it + jump nextPeerPrefix; + } + } + + if(!isVerified) + { + // Mark as verified + integer iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + verified += identifier; + isVerified = TRUE; + } + + // Phase 2: Compare with candidates; Check if already exact match or is shorter, otherwise delete any wider prefixes + integer candidatesIterator = llGetListLength(candidates); + while(candidatesIterator > 0) + { + candidatesIterator -= 2; + string candidatePrefix = llList2String(candidates, candidatesIterator + 1); + + if(peerPrefix == candidatePrefix) + jump nextPeerPrefix; // Already have this candidate; + + // Is candidate shorter than peer? + else if(llSubStringIndex(peerPrefix, candidatePrefix) == 0) + jump nextPeerPrefix; // Skip adding peer as candidate as we already have a better match + + // Is candidate wider than peer? + else if(llSubStringIndex(candidatePrefix, peerPrefix) == 0) + // Delete candidate as peer is a better match + candidates = llDeleteSubList(candidates, candidatesIterator, candidatesIterator + 1); + } + + // Phase 3: Add peer as candidate + candidates += [identifier, peerPrefix]; + + @nextPeerPrefix; + } + @nextPeer; + } + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Sync event + if(eventType == EVENT_SYNC_RESPONSE) + { + // integer action = llOrd(message, 1) - 1; + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + string value = llGetSubString(message, 3 + nameLength, -1); + + if(!inScope(name)) return; + + if(eventFlags & EVENT_FRAGMENT) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & EVENT_FINAL_FRAGMENT) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + llLinksetDataWrite(name, value); + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else llLinksetDataWrite(name, value); + } + + else if(eventType == EVENT_SYNC_COMPLETE) + { + // If no more candidates, move to sync state + if(candidates); else state sync; + + // Pull out the next candidate to sync + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(EVENT_SYNC_REQUEST) + llDumpList2String(peerScopes, llChar(1)) + ); + } + } + + timer() + { + llSetTimerEvent(FALSE); + + // If no candidates, move directly to sync state + if(candidates); else state sync; + + // Sort candidates by key + candidates = llListSort(candidates, 2, TRUE); + + // Pull out a candidate + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(EVENT_SYNC_REQUEST) + llDumpList2String(peerScopes, llChar(1)) + ); + } + + state_exit() + { + llMessageLinked(LINK_SET, MESSAGE_SYNC_READY, "", ""); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Idle away, synchronising data as needed +state sync +{ + on_rez(integer param) { llResetScript(); } + changed(integer change) { if(change & CHANGED_REGION) llResetScript(); } + state_entry() + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) llListen(llList2Integer(scopeChannels, iterator), "", "", ""); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & EVENT_TYPE; + integer eventFlags = header & ~EVENT_TYPE; + + if(eventType == EVENT_PING) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Check if peer prefix matches any of our scopes + integer hasMatch = FALSE; + list matchedScopes; + string peerPrefix = llGetSubString(message, 2 + signatureLength, -1); + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string ownPrefix = llList2String(scopes, iterator); + if(llSubStringIndex(peerPrefix, ownPrefix) == 0) matchedScopes += ownPrefix; + else if(llSubStringIndex(ownPrefix, peerPrefix) == 0) matchedScopes += ownPrefix; + } + if(matchedScopes); else return; + + // Mark as verified + iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + if(llListFindList(verified, [identifier]) == -1) verified += identifier; + + // Respond with pong + signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + signatureLength = llStringLength(signature); + llRegionSayTo(identifier, channel, + llChar(EVENT_PONG) + + llChar(signatureLength) + + signature + + llDumpList2String(matchedScopes, llChar(1)) + ); + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Remote linkset data event + if(eventType == EVENT_DATA) + { + integer action = llOrd(message, 1) - 1; + + if(action == LINKSETDATA_UPDATE) + { + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + + if(!inScope(name)) return; + + string value = llGetSubString(message, 3 + nameLength, -1); + + if(eventFlags & EVENT_FRAGMENT) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & EVENT_FINAL_FRAGMENT) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else + { + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + } + + else if(action == LINKSETDATA_DELETE) + { + string name = llGetSubString(message, 2, -1); + if(!inScope(name)) return; + if(llLinksetDataDelete(name) == LINKSETDATA_OK) pending += [action, name]; + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + integer prefixLength = llOrd(message, 2); + string prefix = llGetSubString(message, 3, 2 + prefixLength); + // if(!inScope(prefix)) return; -- This is complex as the source may be wider or narrower than our scopes but still have relevant data, hmm... + // For now we cheat a bit because delete found can tell us how many were deleted + // There is however danger for overlap between local and synced data if they have the same names but weren't intended to be synced + + // Escape regex special characters and prepare bucket as a regex pattern + string bucket = llGetSubString(message, 3 + prefixLength, -1); + bucket = llReplaceSubString(bucket, ".", "\\.", 0); + bucket = llReplaceSubString(bucket, "^", "\\^", 0); + bucket = llReplaceSubString(bucket, "$", "\\$", 0); + bucket = llReplaceSubString(bucket, "*", "\\*", 0); + bucket = llReplaceSubString(bucket, "+", "\\+", 0); + bucket = llReplaceSubString(bucket, "?", "\\?", 0); + bucket = llReplaceSubString(bucket, "(", "\\(", 0); + bucket = llReplaceSubString(bucket, ")", "\\)", 0); + bucket = llReplaceSubString(bucket, "{", "\\{", 0); + bucket = llReplaceSubString(bucket, "}", "\\}", 0); + bucket = llReplaceSubString(bucket, "|", "\\|", 0); + bucket = "^" + prefix + "(" + llReplaceSubString(bucket, llChar(1), "|", 0) + ")$"; + integer deleted = llList2Integer(llLinksetDataDeleteFound(bucket, ""), 0); + if(deleted) pending += [action, ""]; + } + + else if(action == LINKSETDATA_RESET) + { + string prefix = llGetSubString(message, 2, -1); + if(!inScope(prefix)) return; + // llLinksetDataReset(); -- Takes out all data even locally scoped, so we convert it to a multi-delete instead + integer deleted = llList2Integer(llLinksetDataDeleteFound("^" + prefix, ""), 0); + if(deleted) pending += [LINKSETDATA_MULTIDELETE, ""]; + } + } + + // TODO: Would this be desirable? Would have to specify a common channel though. The idea would be to piggyback on the secure comms to send messages across + // // Remote link message event + // else if(eventType == EVENT_MESSAGE) + // { + // integer textLength = llOrd(message, 1); + // string text = llGetSubString(message, 2, 1 + textLength); + // string text2 = llGetSubString(message, 2 + textLength, -1); + // llMessageLinked(LINK_SET, MESSAGE_SYNC, text, text2); + // } + + // Full synchronisation with peer + else if(eventType == EVENT_SYNC_REQUEST) + { + list requestScopes; + integer requestIterator; + integer requestTotal; + if(llStringLength(message) > 1) + { + requestScopes = llParseString2List(llGetSubString(message, 1, -1), [llChar(1)], []); + requestTotal = llGetListLength(requestScopes); + } + + integer sent = 0; + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string pattern = "^" + llList2String(scopes, iterator); + list names = llLinksetDataFindKeys(pattern, 0, FALSE); // TODO: Change to paginate the results + integer nameIterator = llGetListLength(names); + while(nameIterator --> 0) + { + string name = llList2String(names, nameIterator); + if(requestTotal) + { + for(requestIterator = 0; requestIterator < requestTotal; ++requestIterator) + { + string requestPrefix = llList2String(requestScopes, requestIterator); + if(llSubStringIndex(name, requestPrefix) == 0) jump requestMatch; + } + jump requestSkip; + } + @requestMatch; + + string value = llLinksetDataRead(name); + sendData(identifier, channel, EVENT_SYNC_RESPONSE, name, value); + if((++sent % 32) == 0) llSleep(4/45.); // Avoid flooding + + @requestSkip; + } + } + + llRegionSayTo(identifier, channel, llChar(EVENT_SYNC_COMPLETE)); + } + } + + linkset_data(integer action, string name, string value) + { + // Skip if this was expected and a change by this script from an external source + if(pending) + { + integer pendingAction = llList2Integer(pending, 0); + string pendingName = llList2String(pending, 1); + if(action == pendingAction) + { + if((action == LINKSETDATA_UPDATE || action == LINKSETDATA_DELETE) && name == pendingName) + { pending = llDeleteSubList(pending, 0, 1); return; } + + else // MULTIDELETE or RESET + { pending = llDeleteSubList(pending, 0, 1); return; } + } + } + + if(action == LINKSETDATA_UPDATE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + return sendData(NULL_KEY, channel, EVENT_DATA, name, value); + } + } + } + + else if(action == LINKSETDATA_DELETE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(EVENT_DATA) + llChar(action + 1) + name); + return; + } + } + } + + else if(action == LINKSETDATA_RESET) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(EVENT_DATA) + llChar(action + 1) + prefix); + } + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + list deleted = llParseString2List(name, [","], []); + list known; + integer total = llGetListLength(scopes); + integer iterator = total; + while(iterator --> 0) known += ""; + + do { + name = llList2String(deleted, 0); + deleted = llDeleteSubList(deleted, 0, 0); + + iterator = total; + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + name = llDeleteSubString(name, 0, llStringLength(prefix) - 1); + + string bucket = llList2String(known, iterator); + if(bucket) bucket += llChar(1) + name; else bucket += name; + known = llListReplaceList(known, [bucket], iterator, iterator); + } + } + } while(deleted); + + iterator = total; + while(iterator --> 0) + { + string bucket = llList2String(known, iterator); + if(bucket) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(EVENT_DATA) + llChar(action + 1) + llChar(llStringLength(prefix)) + prefix + bucket); + // TODO: Check if we need to fragment the bucket across multiple messages + } + } + } + } +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl new file mode 100644 index 0000000000..d3eba96a27 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl @@ -0,0 +1,142 @@ +#include "NexiiLSL/utilities.lsl" + +// Available bytes for UTF8 packing: +#define UTF8_1B_MAX 0x7F +#define UTF8_2B_MAX 0x07FF +#define UTF8_3B_MAX 0xFFFF +#define UTF8_4B_MAX 0x10FFFF +#define UTF8_SURROGATE_START 0xD800 +#define UTF8_SURROGATE_END 0xDFFF +#define UTF8_NONCHARACTER_1 0xFFFE +#define UTF8_NONCHARACTER_2 0xFFFF + + +default +{ + state_entry() + { + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned integer (0 to UTF8_4B_MAX-1) + // Can save a byte or two but importantly provides array-like access + { + integer input = 0x10FFFF - 1; + + string compressed = llChar(1 + input); + + integer output = llOrd(compressed, 0) - 1; + + llOwnerSay( + "Compact int: " + (string)input + " => " + compressed + " => " + (string)output + "\n" + + "Compression " + (string)stringBytes((string)input) + "b => " + (string)stringBytes(compressed) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned float + { + #define MAXIMUM 2048.0 + float quantizer = (UTF8_1B_MAX - 1) / RANGE; + + float input = 1337.256; + + string compressed = llChar(1 + integer(input * quantizer); + + float output = (llOrd(compressed, 0) - 1) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact signed float + { + #define RANGE 2048.0 + float quantizer = (UTF8_1B_MAX - 1) / RANGE / 2; + + float input = 1337.256; + + string compressed = llChar(1 + integer(quantizer + input * quantizer); + + float output = ((llOrd(compressed, 0) - 1) - quantizer) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact vector (region coords <0,0,0> to <256,256,4096>) + { + // Quantization from 0-4096 / 0-256 + float xyquant = (UTF8_1B_MAX - 1) / 4096.; + float zquant = (UTF8_1B_MAX - 1) / 256.; + vector a = <250.525, 128.128128, 3059.999>; + + // Compress + string b = llChar(1 + integer(a.x * xyquant)) + + llChar(1 + integer(a.y * xyquant)) + + llChar(1 + integer(a.z * zquant)); + + // Decompress + vector c = < + (llOrd(b, 0) - 1) / xyquant, + (llOrd(b, 1) - 1) / xyquant, + (llOrd(b, 2) - 1) / zquant + >; + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact keys (36 -> 8 chars); Keys are really 16 byte values, so we can pack them tightly + // There is probably a better way to do this to fit into the 4 byte UTF-8 chars and deserialise it inline + { + key a = "64064c89-25aa-008b-cfe7-0c34e28ae523"; + + // Compress + string b = llChar(integer("0x" + llGetSubString(a, 0, 3))) + + llChar(integer("0x" + llGetSubString(a, 4, 7))) + + llChar(integer("0x" + llGetSubString(a, 9, 12))) + + llChar(integer("0x" + llGetSubString(a, 14, 17))) + + llChar(integer("0x" + llGetSubString(a, 19, 22))) + + llChar(integer("0x" + llGetSubString(a, 24, 27))) + + llChar(integer("0x" + llGetSubString(a, 28, 31))) + + llChar(integer("0x" + llGetSubString(a, 32, 35))); + + // Decompress + key c = bits2nybbles(llOrd(b, 0)) + bits2nybbles(llOrd(b, 1)) + + "-" + bits2nybbles(llOrd(b, 2)) + "-" + bits2nybbles(llOrd(b, 3)) + + "-" + bits2nybbles(llOrd(b, 4)) + "-" + bits2nybbles(llOrd(b, 5)) + + bits2nybbles(llOrd(b, 6)) + bits2nybbles(llOrd(b, 7)); + + llOwnerSay( + "Compact Key (36 -> 8): " + (string)a + " => '" + b + "' => " + (string)c + "\n" + + "Compression " + (string)stringBytes(a) + "b => " + (string)stringBytes(b) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Compact rotations (<-1,-1,-1,-1> to <1,1,1,1>) + { + float quantizer = UTF8_4B_MAX / 2; + vector a = <-2, 27, 120>; + rotation b = llEuler2Rot(a * DEG_TO_RAD); + + // Compress + string c = llChar(1 + integer(quantizer + b.x * quantizer)) + + llChar(1 + integer(quantizer + b.y * quantizer)) + + llChar(1 + integer(quantizer + b.z * quantizer)) + + llChar(1 + integer(quantizer + b.s * quantizer)); + + // Decompress + rotation d = < + ((llOrd(c, 0) - 1) - quantizer) / quantizer, + ((llOrd(c, 1) - 1) - quantizer) / quantizer, + ((llOrd(c, 2) - 1) - quantizer) / quantizer, + ((llOrd(c, 3) - 1) - quantizer) / quantizer + >; + + vector e = llRot2Euler(d) * RAD_TO_DEG; + + llOwnerSay( + "Compact rot: " + (string)a + " => " + (string)b + " => '" + c + "' => " + (string)d + " => " + (string)e + "\n" + + "Compression " + (string)stringBytes(llList2CSV([a,b])) + "b => " + (string)stringBytes(c) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + + + } +} \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl new file mode 100644 index 0000000000..8f6d7dd26a --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl @@ -0,0 +1,82 @@ +// This is the heart of managing texture coordinates, by converting pixel coordinates into SL texture uv coords +#define TEXTURE_REPEAT(w, h, textureWidth, textureHeight) +#define TEXTURE_OFFSET(x, y, textureWidth, textureHeight) \ + <((x) - float(textureWidth)/2) / float(textureWidth),\ + -((y) - float(textureHeight)/2) / float(textureHeight), 0> +#define TEXTURE_COORDS(x, y, width, height, textureWidth, textureHeight) \ + ,\ + <((x) - float(textureWidth)/2.) / float(textureWidth),\ + -((y) - float(textureHeight)/2.) / float(textureHeight), 0> +#define TEXTURE_RECT(x, y, width, height, textureWidth, textureHeight) \ + ,\ + <((x) + width/2. - float(textureWidth)/2.) / float(textureWidth),\ + -((y) + height/2. - float(textureHeight)/2.) / float(textureHeight), 0> + +// For rendering icons placed neatly into an even grid layout in a square texture resolution +#define SPRITESHEET(icon, size, resolution) \ + TEXTURE_COORDS(\ + size*.5 + size * icon % (resolution/size),\ + size*.5 - size * icon / llFloor(resolution/size),\ + size, size,\ + resolution, resolution\ + ) + +// For non-uniform grid layouts as well as textures with non-square aspect ratio +#define SPRITESHEET2(icon, iconWidth, iconHeight, textureWidth, textureHeight) \ + TEXTURE_COORDS(\ + iconWidth*.5 + iconWidth * digit % llFloor(textureWidth/iconWidth),\ + iconHeight*.5 - iconHeight * digit / llFloor(textureWidth/iconWidth),\ + iconWidth, iconHeight,\ + textureWidth, textureHeight\ + ) + + +// Compact spritesheet functions by hardcoding resolution and icon sizes +#define TEXTURE_CURSORS "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd" +#define CURSOR(a, b) <256/2048., 256/512., 0>, <(128 + (256*a) - 1024)/2048., (256 - 128 + (256*b))/512., 0> +// equivalent to #define CURSOR(a, b) TEXTURE_REPEAT(256, 256, 2048., 512.), TEXTURE_OFFSET(, , 2048., 512.) + + + +// Old macros moved from utilities.lsl +// #define TEXTURE_SCALE(w, h) +// #define TEXTURE_OFFSET(w, h, x, y) <(w*.5 + x - RESOLUTION*.5) / RESOLUTION, (RESOLUTION*.5 - (h*.5 + y)) / RESOLUTION, 0> +// #define TEXTURE_SIZE(w, h) w / RESOLUTION*.5, h / RESOLUTION*.5 + + +/* +Texture maths, pixels to coords + +default +{ + state_entry() + { + #define RESOLUTION 1024. + #define FACE 4 + + float width = 181.5; + float height = 116.5; + float x = 30; + float y = 608; + + llScaleTexture( + width / RESOLUTION, + height / RESOLUTION, + FACE + ); + llOffsetTexture( + (width*.5 + x - RESOLUTION*.5) / RESOLUTION, + (RESOLUTION*.5 - (height*.5 + y)) / RESOLUTION, + FACE + ); + llSetScale(< + 0.04, + width/RESOLUTION*.5, + height/RESOLUTION*.5 + >); + } +} + +*/ \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl new file mode 100644 index 0000000000..c94172526d --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl @@ -0,0 +1,107 @@ +/* + Compares two timestamps per ISO 8601 format "YYYY-MM-DDThh:mm:ss.ff..fZ" + Returns -1 if a < b, else 1 if a > b else 0 if same + + You can comment/cut out parts of the function if you only care about + date checks or only want fast time checks +*/ +integer CompareTimestamps(string a, string b) +{ + integer aYear = (integer)llGetSubString(a, 0, 3); + integer bYear = (integer)llGetSubString(b, 0, 3); + if(aYear < bYear) return -1; else if(aYear > bYear) return 1; + integer aMonth = (integer)llGetSubString(a, 5, 6); + integer bMonth = (integer)llGetSubString(b, 5, 6); + if(aMonth < bMonth) return -1; else if(aMonth > bMonth) return 1; + integer aDay = (integer)llGetSubString(a, 8, 9); + integer bDay = (integer)llGetSubString(b, 8, 9); + if(aDay < bDay) return -1; else if(aDay > bDay) return 1; + integer aHour = (integer)llGetSubString(a, 11, 12); + integer bHour = (integer)llGetSubString(b, 11, 12); + if(aHour < bHour) return -1; else if(aHour > bHour) return 1; + integer aMinute = (integer)llGetSubString(a, 14, 15); + integer bMinute = (integer)llGetSubString(b, 14, 15); + if(aMinute < bMinute) return -1; else if(aMinute > bMinute) return 1; + float aSecond = (float)llGetSubString(a, 17, -2); + float bSecond = (float)llGetSubString(b, 17, -2); + if(aSecond < bSecond) return -1; else if(aSecond > bSecond) return 1; + return 0; +} + +// Pure string version of https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Unix(string stamp) +{ + integer year = (integer)llGetSubString(stamp, 0, 3); + integer month = (integer)llGetSubString(stamp, 5, 6); + integer day = (integer)llGetSubString(stamp, 8, 9); + integer hours = (integer)llGetSubString(stamp, 11, 12); + integer minutes = (integer)llGetSubString(stamp, 14, 15); + integer seconds = (integer)llGetSubString(stamp, 17, -2); + + year -= 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + + month = ~-month; + day = ~-day; + + integer days = (integer)(year * 365.25 + 0.25) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + day; + + return ( + days * 86400 + + hours * 3600 + + minutes * 60 + + seconds + ); +} + +// Compact Check +integer TimestampIsBefore(string a, string b) +{ + if((integer)llGetSubString(a, 8, 9) < (integer)llGetSubString(b, 8, 9)) return TRUE; + if((integer)llGetSubString(a, 11, 12) < (integer)llGetSubString(b, 11, 12)) return TRUE; + if((integer)llGetSubString(a, 14, 15) < (integer)llGetSubString(b, 14, 15)) return TRUE; + if((float)llGetSubString(a, 17, -2) < (float)llGetSubString(b, 17, -2)) return TRUE; + return FALSE; +} + +integer TimestampCompare(string a, string b) +{ + integer aDate = (integer)(llGetSubString(a, 0, 3) + llGetSubString(a, 5, 6) + llGetSubString(a, 8, 9)); + integer bDate = (integer)(llGetSubString(b, 0, 3) + llGetSubString(b, 5, 6) + llGetSubString(b, 8, 9)); + if(aDate < bDate) return -1; + else if(aDate > bDate) return 1; + float aTime = (float)(llGetSubString(a, 11, 12) + llGetSubString(a, 14, 15) + llGetSubString(a, 17, -1)); + float bTime = (float)(llGetSubString(b, 11, 12) + llGetSubString(b, 14, 15) + llGetSubString(b, 17, -1)); + llOwnerSay("aTime: " + (string)aTime + "; bTime: " + (string)bTime); + if(aTime < bTime) return -1; + else if(aTime > bTime) return 1; + return 0; +} + +// Milliseconds since beginning of month for timestamp +integer Timestamp2Millisec(string stamp) +{ + return (integer)llGetSubString(stamp, 8, 9) * 86400000 + // Days + (integer)llGetSubString(stamp, 11, 12) * 3600000 + // Hours + (integer)llGetSubString(stamp, 14, 15) * 60000 + // Minutes + llRound(((float)llGetSubString(stamp, 17, -2) * 1000.0)) // Seconds.Milliseconds + - 617316353; // Offset to fit between [-617316353,2147483547] +} + +// Seconds since timestamp epoch, per https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Seconds(string stamp) +{ + list parts = llParseString2List(stamp, ["-", "T", ":", "."], []); + integer year = llList2Integer(parts, 0) - 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + integer month = ~-llList2Integer(parts, 1); + return 86400 * (integer(year * 365.25 + 0.25) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + + (~-llList2Integer(parts, 2))) + + llList2Integer(parts, 3) * 3600 + + llList2Integer(parts, 4) * 60 + + llList2Integer(parts, 5); +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl new file mode 100644 index 0000000000..17cdc908ea --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl @@ -0,0 +1,113 @@ +#define isSameOwner(identifier) llGetOwnerKey(identifier) == llGetOwner() +#define notSameOwner(identifier) llGetOwnerKey(identifier) != llGetOwner() + +// For displaying ordinal numbers like 1st, 2nd, 3rd, 20th, 21st, 22nd, etc. +string nth(integer value) +{ + value = value % 10; + if(value == 1) return "st"; + if(value == 2) return "nd"; + if(value == 3) return "rd"; + return "th"; +} + +// Returns the number of bytes a string would take up in UTF-8 encoding +integer stringBytes(string str) { + integer bytes; + integer length = llStringLength(str); + integer iterator; + for(; iterator < length; ++iterator) + { + integer byte = llOrd(str, iterator); + if(byte <= 0x7F) bytes += 1; + else if(byte <= 0x07FF) bytes += 2; + else if(byte <= 0xFFFF) bytes += 3; + else bytes += 4; + } + return bytes; +} + +// Significantly faster +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + + +// From https://wiki.secondlife.com/wiki/Efficient_Hex +string bits2nybbles(integer bits) { + integer lsn; // least significant nybble + string nybbles = ""; + do + nybbles = llGetSubString("0123456789abcdef", lsn = (bits & 0xF), lsn) + nybbles; + while (bits = (0xfffFFFF & (bits >> 4))); + return nybbles; +} + + +string AttachmentPointAsName(integer point) +{ + if(point == ATTACH_HEAD) return "Skull"; + if(point == ATTACH_NOSE) return "Nose"; + if(point == ATTACH_MOUTH) return "Mouth"; + if(point == ATTACH_FACE_TONGUE) return "Tongue"; + if(point == ATTACH_CHIN) return "Chin"; + if(point == ATTACH_FACE_JAW) return "Jaw"; + if(point == ATTACH_LEAR) return "Left Ear"; + if(point == ATTACH_REAR) return "Right Ear"; + if(point == ATTACH_FACE_LEAR) return "Alt Left Ear"; + if(point == ATTACH_FACE_REAR) return "Alt Right Ear"; + if(point == ATTACH_LEYE) return "Left Eye"; + if(point == ATTACH_REYE) return "Right Eye"; + if(point == ATTACH_FACE_LEYE) return "Alt Left Eye"; + if(point == ATTACH_FACE_REYE) return "Alt Right Eye"; + if(point == ATTACH_NECK) return "Neck"; + if(point == ATTACH_LSHOULDER) return "Left Shoulder"; + if(point == ATTACH_RSHOULDER) return "Right Shoulder"; + if(point == ATTACH_LUARM) return "L Upper Arm"; + if(point == ATTACH_RUARM) return "R Upper Arm"; + if(point == ATTACH_LLARM) return "L Lower Arm"; + if(point == ATTACH_RLARM) return "R Lower Arm"; + if(point == ATTACH_LHAND) return "Left Hand"; + if(point == ATTACH_RHAND) return "Right Hand"; + if(point == ATTACH_LHAND_RING1) return "Left Ring Finger"; + if(point == ATTACH_RHAND_RING1) return "Right Ring Finger"; + if(point == ATTACH_LWING) return "Left Wing"; + if(point == ATTACH_RWING) return "Right Wing"; + if(point == ATTACH_CHEST) return "Chest"; + if(point == ATTACH_LEFT_PEC) return "Left Pec"; + if(point == ATTACH_RIGHT_PEC) return "Right Pec"; + if(point == ATTACH_BELLY) return "Stomach"; + if(point == ATTACH_BACK) return "Spine"; + if(point == ATTACH_TAIL_BASE) return "Tail Base"; + if(point == ATTACH_TAIL_TIP) return "Tail Tip"; + if(point == ATTACH_AVATAR_CENTER) return "Avatar Center"; + if(point == ATTACH_PELVIS) return "Pelvis"; + if(point == ATTACH_GROIN) return "Groin"; + if(point == ATTACH_LHIP) return "Left Hip"; + if(point == ATTACH_RHIP) return "Right Hip"; + if(point == ATTACH_LULEG) return "L Upper Leg"; + if(point == ATTACH_RULEG) return "R Upper Leg"; + if(point == ATTACH_RLLEG) return "R Lower Leg"; + if(point == ATTACH_LLLEG) return "L Lower Leg"; + if(point == ATTACH_LFOOT) return "Left Foot"; + if(point == ATTACH_RFOOT) return "Right Foot"; + if(point == ATTACH_HIND_LFOOT) return "Left Hind Foot"; + if(point == ATTACH_HIND_RFOOT) return "Right Hind Foot"; + if(point == ATTACH_HUD_CENTER_2) return "HUD Center 2"; + if(point == ATTACH_HUD_TOP_RIGHT) return "HUD Top Right"; + if(point == ATTACH_HUD_TOP_CENTER) return "HUD Top"; + if(point == ATTACH_HUD_TOP_LEFT) return "HUD Top Left"; + if(point == ATTACH_HUD_CENTER_1) return "HUD Center"; + if(point == ATTACH_HUD_BOTTOM_LEFT) return "HUD Bottom Left"; + if(point == ATTACH_HUD_BOTTOM) return "HUD Bottom"; + if(point == ATTACH_HUD_BOTTOM_RIGHT) return "HUD Bottom Right"; + return (string)point; +} + + + + + + + diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl new file mode 100644 index 0000000000..e3f486278f --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl @@ -0,0 +1,22 @@ +// NexiiLSL's app-uri.lsl (include/NexiiLSL/LICENSE), each of its macros +// called: a type's name as a cast inside a body, and a call's argument +// that is itself a call. +#include "NexiiLSL/app-uri.lsl" + +default +{ + touch_start(integer count) + { + key toucher = llDetectedKey(0); + llOwnerSay(agentURI(toucher)); + llOwnerSay(agentAboutURI(toucher)); + llOwnerSay(regionURI(llGetRegionName())); + llOwnerSay(objectURI(llGetKey())); + llOwnerSay(fullObjectURI(llGetKey(), llGetObjectName(), llGetOwner())); + llOwnerSay(groupURI(llList2Key(llGetObjectDetails(llGetKey(), [OBJECT_GROUP]), 0))); + llOwnerSay(groupAboutURI(NULL_KEY)); + llOwnerSay(experienceURI(NULL_KEY)); + llOwnerSay(chatURI(-42, "hello there")); + llOwnerSay(wikiLink("https://wiki.secondlife.com", "the wiki")); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl new file mode 100644 index 0000000000..d972e32bf0 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl @@ -0,0 +1,32 @@ +// NexiiLSL's app-uri.lsl (include/NexiiLSL/LICENSE), each of its macros +// called: a type's name as a cast inside a body, and a call's argument +// that is itself a call. + + + + + + + + + + + + +default +{ + touch_start(integer count) + { + key toucher = llDetectedKey(0); + llOwnerSay("secondlife:///app/agent/" + ((string)(toucher)) + "/inspect"); + llOwnerSay("secondlife:///app/agent/" + ((string)(toucher)) + "/about"); + llOwnerSay("secondlife:///app/region/" + llEscapeURL(llGetRegionName())); + llOwnerSay("secondlife:///app/objectim/" + ((string)(llGetKey())) + "?name=" + llEscapeURL(llKey2Name(llGetKey())) + "&owner=" + (string)llGetOwnerKey(llGetKey())); + llOwnerSay("secondlife:///app/objectim/" + ((string)(llGetKey())) + "?name=" + llEscapeURL(llGetObjectName()) + "&owner=" + ((string)(llGetOwner()))); + llOwnerSay("secondlife:///app/group/" + ((string)(llList2Key(llGetObjectDetails(llGetKey(), [OBJECT_GROUP]), 0))) + "/inspect"); + llOwnerSay("secondlife:///app/group/" + ((string)(NULL_KEY)) + "/about"); + llOwnerSay("secondlife:///app/experience/" + ((string)(NULL_KEY)) + "/profile"); + llOwnerSay("secondlife:///app/chat/" + ((string)(-42)) + "/" + llEscapeURL("hello there")); + llOwnerSay("[" + "https://wiki.secondlife.com" + " " + "the wiki" + "]"); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl new file mode 100644 index 0000000000..5e9a37e1fc --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl @@ -0,0 +1,4 @@ +// NexiiLSL's damage-types.lsl (include/NexiiLSL/LICENSE): lists of strings +// as #defines indented inside a function, run over many lines by +// backslashes, each called with another of them, and casts inside them. +#include "NexiiLSL/damage-types.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl new file mode 100644 index 0000000000..e58fc6431f --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl @@ -0,0 +1,884 @@ +// NexiiLSL's damage-types.lsl (include/NexiiLSL/LICENSE): lists of strings +// as #defines indented inside a function, run over many lines by +// backslashes, each called with another of them, and casts inside them. + + + + + + + + + +// Returns name of the constant that identifies the damage type +string DamageTypeAsConstant(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "DAMAGE_TYPE_IMPACT"; + if(type == DAMAGE_TYPE_GENERIC) return "DAMAGE_TYPE_GENERIC"; + if(type == DAMAGE_TYPE_ACID) return "DAMAGE_TYPE_ACID"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "DAMAGE_TYPE_BLUDGEONING"; + if(type == DAMAGE_TYPE_COLD) return "DAMAGE_TYPE_COLD"; + if(type == DAMAGE_TYPE_ELECTRIC) return "DAMAGE_TYPE_ELECTRIC"; + if(type == DAMAGE_TYPE_FIRE) return "DAMAGE_TYPE_FIRE"; + if(type == DAMAGE_TYPE_FORCE) return "DAMAGE_TYPE_FORCE"; + if(type == DAMAGE_TYPE_NECROTIC) return "DAMAGE_TYPE_NECROTIC"; + if(type == DAMAGE_TYPE_PIERCING) return "DAMAGE_TYPE_PIERCING"; + if(type == DAMAGE_TYPE_POISON) return "DAMAGE_TYPE_POISON"; + if(type == DAMAGE_TYPE_PSYCHIC) return "DAMAGE_TYPE_PSYCHIC"; + if(type == DAMAGE_TYPE_RADIANT) return "DAMAGE_TYPE_RADIANT"; + if(type == DAMAGE_TYPE_SLASHING) return "DAMAGE_TYPE_SLASHING"; + if(type == DAMAGE_TYPE_SONIC) return "DAMAGE_TYPE_SONIC"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "DAMAGE_TYPE_EMOTIONAL"; + if(type == 100) return "DAMAGE_TYPE_MEDICAL"; + if(type == 101) return "DAMAGE_TYPE_REPAIR"; + if(type == 102) return "DAMAGE_TYPE_EXPLOSIVE"; + if(type == 103) return "DAMAGE_TYPE_CRUSHING"; + if(type == 104) return "DAMAGE_TYPE_ANTI_ARMOR"; + if(type == 105) return "DAMAGE_TYPE_SUFFOCATION"; + if(type == 106) return "DAMAGE_TYPE_REDEPLOY"; + return (string)type; +} + +// Returns damage type as a noun for use in sentences to describe the damage type +string DamageTypeAsNoun(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impact"; + if(type == DAMAGE_TYPE_GENERIC) return "generic"; + if(type == DAMAGE_TYPE_ACID) return "acid"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoning"; + if(type == DAMAGE_TYPE_COLD) return "cold"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electric"; + if(type == DAMAGE_TYPE_FIRE) return "fire"; + if(type == DAMAGE_TYPE_FORCE) return "force"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotic"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "piercing"; + if(type == DAMAGE_TYPE_POISON) return "poison"; + if(type == DAMAGE_TYPE_PSYCHIC) return "psychic"; + if(type == DAMAGE_TYPE_RADIANT) return "radiant"; + if(type == DAMAGE_TYPE_SLASHING) return "slashing"; + if(type == DAMAGE_TYPE_SONIC) return "sonic"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "emotional"; + if(type == 100) return "medical"; + if(type == 101) return "repair"; + if(type == 102) return "explosive"; + if(type == 103) return "crushing"; + if(type == 104) return "anti-armor"; + if(type == 105) return "suffocation"; + if(type == 106) return "redeploy"; + return "unknown"; +} + +// Returns damage type as a verb for use in sentences as how it was applied to a target +string DamageTypeAsVerb(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impacted"; + if(type == DAMAGE_TYPE_GENERIC) return "damaged"; + if(type == DAMAGE_TYPE_ACID) return "corroded"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoned"; + if(type == DAMAGE_TYPE_COLD) return "frozen"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electrocuted"; + if(type == DAMAGE_TYPE_FIRE) return "burnt"; + if(type == DAMAGE_TYPE_FORCE) return "smashed"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotised"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "pierced"; + if(type == DAMAGE_TYPE_POISON) return "poisoned"; + if(type == DAMAGE_TYPE_PSYCHIC) return "assaulted"; + if(type == DAMAGE_TYPE_RADIANT) return "raptured"; + if(type == DAMAGE_TYPE_SLASHING) return "slashed"; + if(type == DAMAGE_TYPE_SONIC) return "ruptured"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "traumatised"; + if(type == 100) return "healed"; + if(type == 101) return "repaired"; + if(type == 102) return "exploded"; + if(type == 103) return "crushed"; + if(type == 104) return "punched through"; + if(type == 105) return "suffocated"; + if(type == 106) return "redeployed"; + return "damaged"; +} + +// Returns PRIM_TEXTURE params for rendering the damage type as an icon +list DamageTypeAsIcon(integer type) +{ + vector offset; integer variant = (integer)llFrand(32.0); + if(type == DAMAGE_TYPE_IMPACT) offset = ; + else if(type == DAMAGE_TYPE_GENERIC) offset = <4, 0, 0>; + else if(type == DAMAGE_TYPE_ACID) offset = <5, 0, 0>; + else if(type == DAMAGE_TYPE_BLUDGEONING) offset = <6, 0, 0>; + else if(type == DAMAGE_TYPE_COLD) offset = <7, 0, 0>; + else if(type == DAMAGE_TYPE_ELECTRIC) offset = <8, 0, 0>; + else if(type == DAMAGE_TYPE_FIRE) offset = <9, 0, 0>; + else if(type == DAMAGE_TYPE_FORCE) offset = <10, 0, 0>; + else if(type == DAMAGE_TYPE_NECROTIC) offset = <11, 0, 0>; + else if(type == DAMAGE_TYPE_PIERCING) offset = <12, 0, 0>; + else if(type == DAMAGE_TYPE_POISON) offset = <13, 0, 0>; + else if(type == DAMAGE_TYPE_PSYCHIC) offset = <14, 0, 0>; + else if(type == DAMAGE_TYPE_RADIANT) offset = <15, 0, 0>; + else if(type == DAMAGE_TYPE_SLASHING) offset = <0, 1, 0>; + else if(type == DAMAGE_TYPE_SONIC) offset = <1 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_EMOTIONAL) offset = <3, 1, 0>; + else if(type == 100) offset = <4 + (variant % 2), 1, 0>; + else if(type == 101) offset = <6 + (variant % 2), 1, 0>; + else if(type == 102) offset = <8, 1, 0>; + else if(type == 103) offset = <9, 1, 0>; + else if(type == 104) offset = <10, 1, 0>; + else if(type == 105) offset = <12, 1, 0>; + else if(type == 106) offset = <8, 3, 0>; + + // These are not damage types but additional icons related to combat + else if(type == -100) offset = <0, 3, 0>; // Death + else if(type == -101) offset = <1, 3, 0>; // Enemy Death + else if(type == -102) offset = <2, 3, 0>; // Friendly Death + else if(type == -103) offset = <3, 3, 0>; // Object Death + else if(type == -104) offset = <4, 3, 0>; // Vehicle Death + else if(type == -105) offset = <5, 3, 0>; // Aircraft Death + else if(type == -106) offset = <6, 3, 0>; // Unconscious/Downed + else if(type == -107) offset = <7, 3, 0>; // Revive Requested + else if(type == -108) offset = <8, 3, 0>; // Being Revived + + /* + Potential other icons: + - Respawn, Redeploy + - Mech Death + - Shield Hit, Armor Hit, Critical Hit, EMP Hit + - Critical Hit, Enemy Critical Hit, Friendly Critical Hit + - Blocked Hit, Enemy Blocked Hit, Friendly Blocked Hit + - Missed Hit, Enemy Missed Hit, Friendly Missed Hit + - Civilian Down, Enemy Down, Friendly Down + - Kill Assist + - Headshot + - Target Locked, Target Lost + - Out of Ammo, Low Ammo, Reloading + - Overheated, Jammed, Hacked, Disarmed, Suppressed, Spotted, Invisible + - Invulnerable, Vulnerable + - Buffed, Debuffed + */ + + return [ + "2d053029-7baa-104d-fd8e-34b2112b4b78", + <128./2048., 128./512., 0>, + <(offset.x - 7.5) / 16, (1.5 - offset.y) / 4, 0>, + 0 + ]; +} + +string DamageTypeAsReason(integer type, key owner, key target) +{ + // Picks a uniformly random entry from a list. Avoids the modulo bias + // of llFrand(64.0) % N and means you never have to update counts when + // adding/removing lines. + + + // ============================================================ + // ATTACKER vs VICTIM (owner != target) + // ============================================================ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + // ============================================================ + // SELF-INFLICTED (owner == target) — TARGET-only phrasing + // ============================================================ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + string reason; + + // Redeploy is always a voluntary self-action, regardless of who is credited + if(type == 106) + { + reason = llList2String([ "TARGET redeployed to a new position", "TARGET returned to the deployment queue", "TARGET chose to redeploy", "TARGET cycled back to spawn", "TARGET withdrew for redeployment", "TARGET repositioned via redeploy", "TARGET abandoned the current position", "TARGET respawned elsewhere", "TARGET reset their tactical situation", "TARGET redeployed out of the combat zone", "TARGET took the express route back to spawn", "TARGET performed a tactical respawn", "TARGET sought better life choices via redeploy", "TARGET changed their mind — and location", "TARGET performed tactical disappearance", "TARGET reconsidered their surroundings", "TARGET pressed the redeploy button", "TARGET found a more survivable ZIP code", "TARGET left to spawn somewhere less explosive", "TARGET took a strategic timeout" ], (integer)llFrand((float)llGetListLength([ "TARGET redeployed to a new position", "TARGET returned to the deployment queue", "TARGET chose to redeploy", "TARGET cycled back to spawn", "TARGET withdrew for redeployment", "TARGET repositioned via redeploy", "TARGET abandoned the current position", "TARGET respawned elsewhere", "TARGET reset their tactical situation", "TARGET redeployed out of the combat zone", "TARGET took the express route back to spawn", "TARGET performed a tactical respawn", "TARGET sought better life choices via redeploy", "TARGET changed their mind — and location", "TARGET performed tactical disappearance", "TARGET reconsidered their surroundings", "TARGET pressed the redeploy button", "TARGET found a more survivable ZIP code", "TARGET left to spawn somewhere less explosive", "TARGET took a strategic timeout" ]))); + } + else if(owner == target) + { + // Self-inflicted: TARGET-only phrasing, no attacker credit + if(type == DAMAGE_TYPE_IMPACT) reason = llList2String([ "TARGET punched the ground. The ground punched back", "TARGET tested the structural integrity of a nearby wall", "TARGET tested the structural integrity of a nearby floor", "TARGET tested the structural integrity of a nearby ceiling", "TARGET discovered the upper limit of their movement enhancer", "TARGET achieved terminal mobility", "TARGET mistook momentum for a survival strategy", "TARGET lost a close-quarters engagement with the terrain", "TARGET deployed directly into solid matter", "TARGET accelerated confidently into disaster", "TARGET's movement tech delivered exactly as advertised", "TARGET learned that velocity has consequences", "TARGET attempted advanced maneuvering without adult supervision", "TARGET converted speed into an immediate medical issue", "TARGET overshot the objective by several meters and one life", "TARGET used tactical sprint offensively against themselves", "TARGET was outplayed by basic map geometry", "TARGET trusted the jump pack. The jump pack disagreed", "TARGET discovered an undocumented interaction between boosters and concrete", "TARGET briefly achieved flight before remembering they weren't aviation-capable", "TARGET's landing request was denied by physics", "TARGET found the map boundary emotionally and physically", "TARGET turned mobility equipment into a kinetic weapon against TARGET", "TARGET's combat stim demanded speed; the wall accepted payment", "TARGET exceeded recommended operating velocity", "TARGET weaponized their own momentum", "TARGET slammed into cover with unnecessary enthusiasm", "TARGET's parkour certification has been revoked", "TARGET hit mach jesus and forgot the braking phase", "TARGET went from combatant to projectile", "TARGET speedran the respawn screen", "TARGET demonstrated why boosters come with warning labels", "TARGET challenged Newtonian mechanics and lost unanimously", "TARGET's movement enhancer secured a confirmed kill", "TARGET achieved excellent airtime and poor decision-making", "TARGET discovered a new route directly into the ground", "TARGET attempted vertical gameplay and received horizontal consequences", "TARGET became one with nearby infrastructure" ], (integer)llFrand((float)llGetListLength([ "TARGET punched the ground. The ground punched back", "TARGET tested the structural integrity of a nearby wall", "TARGET tested the structural integrity of a nearby floor", "TARGET tested the structural integrity of a nearby ceiling", "TARGET discovered the upper limit of their movement enhancer", "TARGET achieved terminal mobility", "TARGET mistook momentum for a survival strategy", "TARGET lost a close-quarters engagement with the terrain", "TARGET deployed directly into solid matter", "TARGET accelerated confidently into disaster", "TARGET's movement tech delivered exactly as advertised", "TARGET learned that velocity has consequences", "TARGET attempted advanced maneuvering without adult supervision", "TARGET converted speed into an immediate medical issue", "TARGET overshot the objective by several meters and one life", "TARGET used tactical sprint offensively against themselves", "TARGET was outplayed by basic map geometry", "TARGET trusted the jump pack. The jump pack disagreed", "TARGET discovered an undocumented interaction between boosters and concrete", "TARGET briefly achieved flight before remembering they weren't aviation-capable", "TARGET's landing request was denied by physics", "TARGET found the map boundary emotionally and physically", "TARGET turned mobility equipment into a kinetic weapon against TARGET", "TARGET's combat stim demanded speed; the wall accepted payment", "TARGET exceeded recommended operating velocity", "TARGET weaponized their own momentum", "TARGET slammed into cover with unnecessary enthusiasm", "TARGET's parkour certification has been revoked", "TARGET hit mach jesus and forgot the braking phase", "TARGET went from combatant to projectile", "TARGET speedran the respawn screen", "TARGET demonstrated why boosters come with warning labels", "TARGET challenged Newtonian mechanics and lost unanimously", "TARGET's movement enhancer secured a confirmed kill", "TARGET achieved excellent airtime and poor decision-making", "TARGET discovered a new route directly into the ground", "TARGET attempted vertical gameplay and received horizontal consequences", "TARGET became one with nearby infrastructure" ]))); + else if(type == DAMAGE_TYPE_GENERIC) reason = llList2String([ "TARGET eliminated TARGET. Efficient", "TARGET cut out the middleman and killed themselves", "TARGET found the enemy. It was TARGET", "TARGET secured a confirmed kill on TARGET", "TARGET defeated their greatest opponent: TARGET", "TARGET needed no assistance dying", "TARGET took themselves out of the fight", "TARGET saved the enemy some ammunition" ], (integer)llFrand((float)llGetListLength([ "TARGET eliminated TARGET. Efficient", "TARGET cut out the middleman and killed themselves", "TARGET found the enemy. It was TARGET", "TARGET secured a confirmed kill on TARGET", "TARGET defeated their greatest opponent: TARGET", "TARGET needed no assistance dying", "TARGET took themselves out of the fight", "TARGET saved the enemy some ammunition" ]))); + else if(type == DAMAGE_TYPE_ACID) reason = llList2String([ "TARGET dissolved in their own corrosives", "TARGET learned that acid doesn't check IFF", "TARGET became the test subject of their own chemistry experiment", "TARGET stored the acid incorrectly: on themselves", "TARGET handled corrosives with confidence instead of gloves", "TARGET's chemical weapon chose violence locally", "TARGET self-liquefied", "TARGET reduced their own gear, and TARGET, to sludge" ], (integer)llFrand((float)llGetListLength([ "TARGET dissolved in their own corrosives", "TARGET learned that acid doesn't check IFF", "TARGET became the test subject of their own chemistry experiment", "TARGET stored the acid incorrectly: on themselves", "TARGET handled corrosives with confidence instead of gloves", "TARGET's chemical weapon chose violence locally", "TARGET self-liquefied", "TARGET reduced their own gear, and TARGET, to sludge" ]))); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = llList2String([ "TARGET beat themselves to the punch. Fatally", "TARGET delivered blunt trauma to the nearest available skull: their own", "TARGET lost a fistfight against their own equipment", "TARGET swung hard and connected with TARGET", "TARGET self-administered percussive maintenance", "TARGET was somehow on both ends of the hammer", "TARGET flattened the closest combatant: TARGET", "TARGET hammered themselves into the dirt unassisted" ], (integer)llFrand((float)llGetListLength([ "TARGET beat themselves to the punch. Fatally", "TARGET delivered blunt trauma to the nearest available skull: their own", "TARGET lost a fistfight against their own equipment", "TARGET swung hard and connected with TARGET", "TARGET self-administered percussive maintenance", "TARGET was somehow on both ends of the hammer", "TARGET flattened the closest combatant: TARGET", "TARGET hammered themselves into the dirt unassisted" ]))); + else if(type == DAMAGE_TYPE_COLD) reason = llList2String([ "TARGET froze themselves solid", "TARGET stress-tested their own cryogenics from the inside", "TARGET achieved self-refrigeration", "TARGET forgot which end of the cryo weapon was the cold one", "TARGET preserved themselves for future generations", "TARGET lost the heat war against TARGET", "TARGET turned themselves into a frozen casualty", "TARGET weaponized winter against the only person in range: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET froze themselves solid", "TARGET stress-tested their own cryogenics from the inside", "TARGET achieved self-refrigeration", "TARGET forgot which end of the cryo weapon was the cold one", "TARGET preserved themselves for future generations", "TARGET lost the heat war against TARGET", "TARGET turned themselves into a frozen casualty", "TARGET weaponized winter against the only person in range: TARGET" ]))); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = llList2String([ "TARGET electrocuted themselves", "TARGET completed the circuit personally", "TARGET discovered they were the path of least resistance", "TARGET grounded themselves. Permanently", "TARGET conducted a one-person electrical safety demonstration", "TARGET's high-voltage solution solved TARGET", "TARGET short-circuited their own nervous system", "TARGET fried the nearest conductor: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET electrocuted themselves", "TARGET completed the circuit personally", "TARGET discovered they were the path of least resistance", "TARGET grounded themselves. Permanently", "TARGET conducted a one-person electrical safety demonstration", "TARGET's high-voltage solution solved TARGET", "TARGET short-circuited their own nervous system", "TARGET fried the nearest conductor: TARGET" ]))); + else if(type == DAMAGE_TYPE_FIRE) reason = llList2String([ "TARGET self-ignited", "TARGET stood in their own fire and called it warmth", "TARGET deployed incendiaries at extremely personal range", "TARGET became their own thermal signature", "TARGET played with fire and lost custody", "TARGET achieved self-cremation ahead of schedule", "TARGET lost the thermal exchange with TARGET", "TARGET's flames showed no brand loyalty" ], (integer)llFrand((float)llGetListLength([ "TARGET self-ignited", "TARGET stood in their own fire and called it warmth", "TARGET deployed incendiaries at extremely personal range", "TARGET became their own thermal signature", "TARGET played with fire and lost custody", "TARGET achieved self-cremation ahead of schedule", "TARGET lost the thermal exchange with TARGET", "TARGET's flames showed no brand loyalty" ]))); + else if(type == DAMAGE_TYPE_FORCE) reason = llList2String([ "TARGET blasted themselves apart", "TARGET stood at the business end of their own shockwave", "TARGET applied devastating force in the wrong direction", "TARGET freight-trained TARGET", "TARGET violently displaced themselves", "TARGET's raw power needed a target. TARGET volunteered", "TARGET blew themselves clean off their own position", "TARGET found their own impact unsurvivable" ], (integer)llFrand((float)llGetListLength([ "TARGET blasted themselves apart", "TARGET stood at the business end of their own shockwave", "TARGET applied devastating force in the wrong direction", "TARGET freight-trained TARGET", "TARGET violently displaced themselves", "TARGET's raw power needed a target. TARGET volunteered", "TARGET blew themselves clean off their own position", "TARGET found their own impact unsurvivable" ]))); + else if(type == DAMAGE_TYPE_NECROTIC) reason = llList2String([ "TARGET drained their own life force", "TARGET accelerated their own decay considerably", "TARGET reduced themselves to a husk", "TARGET's life force resigned without notice", "TARGET consumed their own vitality. Bold strategy", "TARGET brought slow death to the nearest lifeform: TARGET", "TARGET faded themselves out", "TARGET left TARGET biologically ruined" ], (integer)llFrand((float)llGetListLength([ "TARGET drained their own life force", "TARGET accelerated their own decay considerably", "TARGET reduced themselves to a husk", "TARGET's life force resigned without notice", "TARGET consumed their own vitality. Bold strategy", "TARGET brought slow death to the nearest lifeform: TARGET", "TARGET faded themselves out", "TARGET left TARGET biologically ruined" ]))); + else if(type == DAMAGE_TYPE_PIERCING) reason = llList2String([ "TARGET shot themselves", "TARGET checked if the gun was loaded the hard way", "TARGET caught a burst from a familiar rifle: their own", "TARGET lost a firefight with zero other participants", "TARGET demonstrated muzzle awareness by counterexample", "TARGET filled themselves with lead", "TARGET ventilated TARGET", "TARGET landed center-mass hits on the wrong center mass" ], (integer)llFrand((float)llGetListLength([ "TARGET shot themselves", "TARGET checked if the gun was loaded the hard way", "TARGET caught a burst from a familiar rifle: their own", "TARGET lost a firefight with zero other participants", "TARGET demonstrated muzzle awareness by counterexample", "TARGET filled themselves with lead", "TARGET ventilated TARGET", "TARGET landed center-mass hits on the wrong center mass" ]))); + else if(type == DAMAGE_TYPE_POISON) reason = llList2String([ "TARGET poisoned themselves", "TARGET taste-tested their own toxins", "TARGET confused the antidote with the dosage", "TARGET's chemical weapon worked exactly once, on TARGET", "TARGET self-administered a lethal dose", "TARGET trusted their own labeling system incorrectly", "TARGET's body gave up after consulting with TARGET", "TARGET delivered a toxic end to TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET poisoned themselves", "TARGET taste-tested their own toxins", "TARGET confused the antidote with the dosage", "TARGET's chemical weapon worked exactly once, on TARGET", "TARGET self-administered a lethal dose", "TARGET trusted their own labeling system incorrectly", "TARGET's body gave up after consulting with TARGET", "TARGET delivered a toxic end to TARGET" ]))); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = llList2String([ "TARGET thought themselves to death", "TARGET lost an argument inside their own head", "TARGET's mind overloaded itself", "TARGET overthought a survivable situation. Fatally", "TARGET was psychologically neutralized by TARGET", "TARGET won the cognitive battle and also lost it", "TARGET shattered their own mind unassisted", "TARGET gave TARGET more than TARGET could process" ], (integer)llFrand((float)llGetListLength([ "TARGET thought themselves to death", "TARGET lost an argument inside their own head", "TARGET's mind overloaded itself", "TARGET overthought a survivable situation. Fatally", "TARGET was psychologically neutralized by TARGET", "TARGET won the cognitive battle and also lost it", "TARGET shattered their own mind unassisted", "TARGET gave TARGET more than TARGET could process" ]))); + else if(type == DAMAGE_TYPE_RADIANT) reason = llList2String([ "TARGET irradiated themselves", "TARGET stood inside their own energy discharge", "TARGET glowed with self-confidence. Then just glowed", "TARGET ignored the safe operating distance of their own weapon", "TARGET achieved self-illumination. Briefly", "TARGET exposed themselves to lethal radiation. Their own", "TARGET outshone TARGET fatally", "TARGET annihilated the closest energy signature: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET irradiated themselves", "TARGET stood inside their own energy discharge", "TARGET glowed with self-confidence. Then just glowed", "TARGET ignored the safe operating distance of their own weapon", "TARGET achieved self-illumination. Briefly", "TARGET exposed themselves to lethal radiation. Their own", "TARGET outshone TARGET fatally", "TARGET annihilated the closest energy signature: TARGET" ]))); + else if(type == DAMAGE_TYPE_SLASHING) reason = llList2String([ "TARGET cut themselves down", "TARGET lost a knife fight against TARGET", "TARGET found the sharp end of their own blade", "TARGET demonstrated blade safety by counterexample", "TARGET sliced through the nearest combatant: TARGET", "TARGET made short work of themselves", "TARGET opened TARGET up. No assistance required", "TARGET's blade showed no loyalty" ], (integer)llFrand((float)llGetListLength([ "TARGET cut themselves down", "TARGET lost a knife fight against TARGET", "TARGET found the sharp end of their own blade", "TARGET demonstrated blade safety by counterexample", "TARGET sliced through the nearest combatant: TARGET", "TARGET made short work of themselves", "TARGET opened TARGET up. No assistance required", "TARGET's blade showed no loyalty" ]))); + else if(type == DAMAGE_TYPE_SONIC) reason = llList2String([ "TARGET deafened themselves to death", "TARGET stood inside their own pressure wave", "TARGET lost the volume war against TARGET", "TARGET turned the acoustic weapon up to eleven while holding it", "TARGET concussed themselves with their own bass drop", "TARGET's sonic blast didn't discriminate", "TARGET collapsed their own senses", "TARGET weaponized sound against the closest pair of ears: their own" ], (integer)llFrand((float)llGetListLength([ "TARGET deafened themselves to death", "TARGET stood inside their own pressure wave", "TARGET lost the volume war against TARGET", "TARGET turned the acoustic weapon up to eleven while holding it", "TARGET concussed themselves with their own bass drop", "TARGET's sonic blast didn't discriminate", "TARGET collapsed their own senses", "TARGET weaponized sound against the closest pair of ears: their own" ]))); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = llList2String([ "TARGET's morale collapsed under TARGET", "TARGET lost the psychological war with themselves", "TARGET took the loss personally. From themselves", "TARGET crushed their own confidence fatally", "TARGET broke their own spirit before the enemy could", "TARGET was their own harshest critic. Lethally", "TARGET applied pressure TARGET never recovered from", "TARGET emotionally devastated TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET's morale collapsed under TARGET", "TARGET lost the psychological war with themselves", "TARGET took the loss personally. From themselves", "TARGET crushed their own confidence fatally", "TARGET broke their own spirit before the enemy could", "TARGET was their own harshest critic. Lethally", "TARGET applied pressure TARGET never recovered from", "TARGET emotionally devastated TARGET" ]))); + else if(type == 100) reason = llList2String([ "TARGET self-medicated into the afterlife", "TARGET's self-treatment proved terminal", "TARGET read the medkit instructions posthumously", "TARGET healed themselves to death", "TARGET trusted their own medical credentials incorrectly", "TARGET flatlined under their own care", "TARGET gave TARGET one treatment too many", "TARGET's bedside manner killed the only patient available: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET self-medicated into the afterlife", "TARGET's self-treatment proved terminal", "TARGET read the medkit instructions posthumously", "TARGET healed themselves to death", "TARGET trusted their own medical credentials incorrectly", "TARGET flatlined under their own care", "TARGET gave TARGET one treatment too many", "TARGET's bedside manner killed the only patient available: TARGET" ]))); + else if(type == 101) reason = llList2String([ "TARGET repaired themselves into catastrophic failure", "TARGET performed rapid unscheduled self-disassembly", "TARGET overclocked themselves to destruction", "TARGET voided their own warranty", "TARGET's self-maintenance proved unsurvivable", "TARGET pushed TARGET beyond operational limits", "TARGET turned self-maintenance into a confirmed kill", "TARGET rendered TARGET permanently nonfunctional" ], (integer)llFrand((float)llGetListLength([ "TARGET repaired themselves into catastrophic failure", "TARGET performed rapid unscheduled self-disassembly", "TARGET overclocked themselves to destruction", "TARGET voided their own warranty", "TARGET's self-maintenance proved unsurvivable", "TARGET pushed TARGET beyond operational limits", "TARGET turned self-maintenance into a confirmed kill", "TARGET rendered TARGET permanently nonfunctional" ]))); + else if(type == 102) reason = llList2String([ "TARGET held the grenade slightly too long", "TARGET stood in their own blast radius", "TARGET cooked a grenade past the recommended doneness", "TARGET became the epicenter", "TARGET's ordnance arrived before TARGET left", "TARGET reduced themselves to debris", "TARGET couldn't outrun their own explosives", "TARGET ate their own grenade. Chef's kiss" ], (integer)llFrand((float)llGetListLength([ "TARGET held the grenade slightly too long", "TARGET stood in their own blast radius", "TARGET cooked a grenade past the recommended doneness", "TARGET became the epicenter", "TARGET's ordnance arrived before TARGET left", "TARGET reduced themselves to debris", "TARGET couldn't outrun their own explosives", "TARGET ate their own grenade. Chef's kiss" ]))); + else if(type == 103) reason = llList2String([ "TARGET crushed themselves", "TARGET was flattened by their own equipment", "TARGET ran a compression test on TARGET. Both sides failed", "TARGET parked something heavy on top of themselves", "TARGET folded under pressure of their own making", "TARGET compacted themselves for easy storage", "TARGET reduced TARGET to scrap", "TARGET turned pressure into a weapon against the only one in range: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET crushed themselves", "TARGET was flattened by their own equipment", "TARGET ran a compression test on TARGET. Both sides failed", "TARGET parked something heavy on top of themselves", "TARGET folded under pressure of their own making", "TARGET compacted themselves for easy storage", "TARGET reduced TARGET to scrap", "TARGET turned pressure into a weapon against the only one in range: TARGET" ]))); + else if(type == 104) reason = llList2String([ "TARGET penetrated their own armor", "TARGET proved their own armor was a suggestion", "TARGET tested AP rounds on the nearest armor: their own", "TARGET turned their armor into a liability personally", "TARGET cracked themselves open with anti-vehicle fire", "TARGET won and lost the armor check simultaneously", "TARGET's penetrators went straight through TARGET", "TARGET gutted TARGET with heavy ordnance. Impressive angle" ], (integer)llFrand((float)llGetListLength([ "TARGET penetrated their own armor", "TARGET proved their own armor was a suggestion", "TARGET tested AP rounds on the nearest armor: their own", "TARGET turned their armor into a liability personally", "TARGET cracked themselves open with anti-vehicle fire", "TARGET won and lost the armor check simultaneously", "TARGET's penetrators went straight through TARGET", "TARGET gutted TARGET with heavy ordnance. Impressive angle" ]))); + else if(type == 105) reason = llList2String([ "TARGET forgot breathing was mandatory", "TARGET ran out of air with no help from anyone", "TARGET revoked their own oxygen access", "TARGET held their breath competitively against themselves", "TARGET treated oxygen as optional", "TARGET asphyxiated unassisted", "TARGET removed breathing from their own options", "TARGET turned atmosphere into a privilege TARGET didn't have" ], (integer)llFrand((float)llGetListLength([ "TARGET forgot breathing was mandatory", "TARGET ran out of air with no help from anyone", "TARGET revoked their own oxygen access", "TARGET held their breath competitively against themselves", "TARGET treated oxygen as optional", "TARGET asphyxiated unassisted", "TARGET removed breathing from their own options", "TARGET turned atmosphere into a privilege TARGET didn't have" ]))); + else if(type == -100) reason = "TARGET team-killed the only teammate within reach: TARGET"; + else reason = "TARGET was killed by TARGET's own " + DamageTypeAsNoun(type) + " damage. Impressive"; + } + else + { + // Attacker vs victim: OWNER-first phrasing + if(type == DAMAGE_TYPE_IMPACT) reason = llList2String([ "OWNER knocked TARGET into a wall", "OWNER slammed TARGET into cover", "OWNER sent TARGET crashing into terrain", "OWNER introduced TARGET to the ground at speed", "OWNER forced TARGET into a fatal collision", "OWNER's fire cancelled TARGET's momentum permanently", "OWNER knocked TARGET off balance and into solid concrete", "OWNER pressured TARGET into pancaking against the environment", "OWNER turned gravity against TARGET", "OWNER arranged a high-speed argument between TARGET and physics. Physics won" ], (integer)llFrand((float)llGetListLength([ "OWNER knocked TARGET into a wall", "OWNER slammed TARGET into cover", "OWNER sent TARGET crashing into terrain", "OWNER introduced TARGET to the ground at speed", "OWNER forced TARGET into a fatal collision", "OWNER's fire cancelled TARGET's momentum permanently", "OWNER knocked TARGET off balance and into solid concrete", "OWNER pressured TARGET into pancaking against the environment", "OWNER turned gravity against TARGET", "OWNER arranged a high-speed argument between TARGET and physics. Physics won" ]))); + else if(type == DAMAGE_TYPE_GENERIC) reason = llList2String([ "OWNER killed TARGET", "OWNER eliminated TARGET", "OWNER took down TARGET", "OWNER neutralized TARGET", "OWNER removed TARGET from the battlefield", "OWNER put TARGET down", "OWNER dropped TARGET", "OWNER defeated TARGET", "OWNER finished TARGET", "OWNER took TARGET out of the fight" ], (integer)llFrand((float)llGetListLength([ "OWNER killed TARGET", "OWNER eliminated TARGET", "OWNER took down TARGET", "OWNER neutralized TARGET", "OWNER removed TARGET from the battlefield", "OWNER put TARGET down", "OWNER dropped TARGET", "OWNER defeated TARGET", "OWNER finished TARGET", "OWNER took TARGET out of the fight" ]))); + else if(type == DAMAGE_TYPE_ACID) reason = llList2String([ "OWNER dissolved TARGET with corrosives", "OWNER melted TARGET with a chemical attack", "OWNER splashed TARGET with lethal acid", "OWNER delivered a corrosive payload directly to TARGET", "OWNER reduced TARGET's gear to sludge", "OWNER chemically erased TARGET", "OWNER turned chemistry into a weapon against TARGET", "OWNER's acid attack proved too much for TARGET", "OWNER corroded TARGET beyond repair", "OWNER liquefied TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER dissolved TARGET with corrosives", "OWNER melted TARGET with a chemical attack", "OWNER splashed TARGET with lethal acid", "OWNER delivered a corrosive payload directly to TARGET", "OWNER reduced TARGET's gear to sludge", "OWNER chemically erased TARGET", "OWNER turned chemistry into a weapon against TARGET", "OWNER's acid attack proved too much for TARGET", "OWNER corroded TARGET beyond repair", "OWNER liquefied TARGET" ]))); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = llList2String([ "OWNER beat TARGET down", "OWNER crushed TARGET with blunt force", "OWNER smashed TARGET into submission", "OWNER landed a fatal blunt hit on TARGET", "OWNER battered TARGET into the dirt", "OWNER flattened TARGET", "OWNER hammered TARGET", "OWNER's heavy impact ended TARGET", "OWNER delivered overwhelming blunt trauma to TARGET", "OWNER broke TARGET with a single strike" ], (integer)llFrand((float)llGetListLength([ "OWNER beat TARGET down", "OWNER crushed TARGET with blunt force", "OWNER smashed TARGET into submission", "OWNER landed a fatal blunt hit on TARGET", "OWNER battered TARGET into the dirt", "OWNER flattened TARGET", "OWNER hammered TARGET", "OWNER's heavy impact ended TARGET", "OWNER delivered overwhelming blunt trauma to TARGET", "OWNER broke TARGET with a single strike" ]))); + else if(type == DAMAGE_TYPE_COLD) reason = llList2String([ "OWNER froze TARGET solid", "OWNER's cryogenic attack claimed TARGET", "OWNER chilled TARGET beyond recovery", "OWNER iced TARGET over", "OWNER turned TARGET into a frozen casualty", "OWNER subjected TARGET to lethal cold exposure", "OWNER locked TARGET down with extreme cold", "OWNER left TARGET frozen mid-fight", "OWNER weaponized winter against TARGET", "OWNER won the heat war against TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER froze TARGET solid", "OWNER's cryogenic attack claimed TARGET", "OWNER chilled TARGET beyond recovery", "OWNER iced TARGET over", "OWNER turned TARGET into a frozen casualty", "OWNER subjected TARGET to lethal cold exposure", "OWNER locked TARGET down with extreme cold", "OWNER left TARGET frozen mid-fight", "OWNER weaponized winter against TARGET", "OWNER won the heat war against TARGET" ]))); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = llList2String([ "OWNER electrocuted TARGET", "OWNER shocked TARGET to death", "OWNER overloaded TARGET with electricity", "OWNER delivered lethal voltage to TARGET", "OWNER lit TARGET up with electrical discharge", "OWNER shut down TARGET's nervous system", "OWNER short-circuited TARGET", "OWNER applied a high-voltage solution to TARGET", "OWNER fried TARGET", "OWNER made TARGET spark out" ], (integer)llFrand((float)llGetListLength([ "OWNER electrocuted TARGET", "OWNER shocked TARGET to death", "OWNER overloaded TARGET with electricity", "OWNER delivered lethal voltage to TARGET", "OWNER lit TARGET up with electrical discharge", "OWNER shut down TARGET's nervous system", "OWNER short-circuited TARGET", "OWNER applied a high-voltage solution to TARGET", "OWNER fried TARGET", "OWNER made TARGET spark out" ]))); + else if(type == DAMAGE_TYPE_FIRE) reason = llList2String([ "OWNER burned TARGET alive", "OWNER incinerated TARGET", "OWNER engulfed TARGET in flames", "OWNER cooked TARGET with incendiaries", "OWNER reduced TARGET to ashes", "OWNER set TARGET on fire. Permanently", "OWNER scorched TARGET off the battlefield", "OWNER won the thermal exchange with TARGET", "OWNER roasted TARGET", "OWNER's flames consumed TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER burned TARGET alive", "OWNER incinerated TARGET", "OWNER engulfed TARGET in flames", "OWNER cooked TARGET with incendiaries", "OWNER reduced TARGET to ashes", "OWNER set TARGET on fire. Permanently", "OWNER scorched TARGET off the battlefield", "OWNER won the thermal exchange with TARGET", "OWNER roasted TARGET", "OWNER's flames consumed TARGET" ]))); + else if(type == DAMAGE_TYPE_FORCE) reason = llList2String([ "OWNER blasted TARGET apart", "OWNER launched TARGET", "OWNER overwhelmed TARGET with raw force", "OWNER hit TARGET with a fatal shockwave", "OWNER hit TARGET with devastating force", "OWNER's impact proved unsurvivable for TARGET", "OWNER violently displaced TARGET", "OWNER freight-trained TARGET", "OWNER crushed TARGET with concussive power", "OWNER blew TARGET clean off their position" ], (integer)llFrand((float)llGetListLength([ "OWNER blasted TARGET apart", "OWNER launched TARGET", "OWNER overwhelmed TARGET with raw force", "OWNER hit TARGET with a fatal shockwave", "OWNER hit TARGET with devastating force", "OWNER's impact proved unsurvivable for TARGET", "OWNER violently displaced TARGET", "OWNER freight-trained TARGET", "OWNER crushed TARGET with concussive power", "OWNER blew TARGET clean off their position" ]))); + else if(type == DAMAGE_TYPE_NECROTIC) reason = llList2String([ "OWNER drained the life from TARGET", "OWNER withered TARGET away", "OWNER consumed TARGET's vitality", "OWNER accelerated TARGET's decay considerably", "OWNER left TARGET biologically ruined", "OWNER's lethal touch faded TARGET out", "OWNER reduced TARGET to a husk", "OWNER outlasted TARGET's life force", "OWNER brought slow death to TARGET", "OWNER's necrotic damage claimed TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER drained the life from TARGET", "OWNER withered TARGET away", "OWNER consumed TARGET's vitality", "OWNER accelerated TARGET's decay considerably", "OWNER left TARGET biologically ruined", "OWNER's lethal touch faded TARGET out", "OWNER reduced TARGET to a husk", "OWNER outlasted TARGET's life force", "OWNER brought slow death to TARGET", "OWNER's necrotic damage claimed TARGET" ]))); + else if(type == DAMAGE_TYPE_PIERCING) reason = llList2String([ "OWNER riddled TARGET with bullets", "OWNER cut TARGET down with rifle fire", "OWNER stitched TARGET with automatic fire", "OWNER put a burst into TARGET", "OWNER perforated TARGET with incoming rounds", "OWNER drilled TARGET with gunfire", "OWNER landed center-mass hits on TARGET", "OWNER won the firefight against TARGET", "OWNER filled TARGET with lead", "OWNER ventilated TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER riddled TARGET with bullets", "OWNER cut TARGET down with rifle fire", "OWNER stitched TARGET with automatic fire", "OWNER put a burst into TARGET", "OWNER perforated TARGET with incoming rounds", "OWNER drilled TARGET with gunfire", "OWNER landed center-mass hits on TARGET", "OWNER won the firefight against TARGET", "OWNER filled TARGET with lead", "OWNER ventilated TARGET" ]))); + else if(type == DAMAGE_TYPE_POISON) reason = llList2String([ "OWNER poisoned TARGET", "OWNER's toxins claimed TARGET", "OWNER contaminated TARGET fatally", "OWNER chemically deteriorated TARGET", "OWNER dosed TARGET with lethal poison", "OWNER convinced TARGET's body to give up", "OWNER weaponized toxins against TARGET", "OWNER's poison ran its full course on TARGET", "OWNER delivered a toxic end to TARGET", "OWNER gave TARGET a fatal chemical exposure" ], (integer)llFrand((float)llGetListLength([ "OWNER poisoned TARGET", "OWNER's toxins claimed TARGET", "OWNER contaminated TARGET fatally", "OWNER chemically deteriorated TARGET", "OWNER dosed TARGET with lethal poison", "OWNER convinced TARGET's body to give up", "OWNER weaponized toxins against TARGET", "OWNER's poison ran its full course on TARGET", "OWNER delivered a toxic end to TARGET", "OWNER gave TARGET a fatal chemical exposure" ]))); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = llList2String([ "OWNER shattered TARGET's mind", "OWNER caused TARGET's complete mental collapse", "OWNER overwhelmed TARGET psychologically", "OWNER out-thought TARGET fatally", "OWNER broke TARGET mentally", "OWNER won the cognitive battle against TARGET", "OWNER overloaded TARGET's mind", "OWNER psychologically neutralized TARGET", "OWNER destabilized TARGET completely", "OWNER gave TARGET more than TARGET could process" ], (integer)llFrand((float)llGetListLength([ "OWNER shattered TARGET's mind", "OWNER caused TARGET's complete mental collapse", "OWNER overwhelmed TARGET psychologically", "OWNER out-thought TARGET fatally", "OWNER broke TARGET mentally", "OWNER won the cognitive battle against TARGET", "OWNER overloaded TARGET's mind", "OWNER psychologically neutralized TARGET", "OWNER destabilized TARGET completely", "OWNER gave TARGET more than TARGET could process" ]))); + else if(type == DAMAGE_TYPE_RADIANT) reason = llList2String([ "OWNER overwhelmed TARGET with radiant energy", "OWNER burned TARGET with radiation", "OWNER blasted TARGET with destructive light", "OWNER made TARGET glow. Briefly", "OWNER irradiated TARGET fatally", "OWNER's energy discharge claimed TARGET", "OWNER seared TARGET with pure energy", "OWNER outshone TARGET fatally", "OWNER exposed TARGET to lethal radiation", "OWNER annihilated TARGET with raw energy" ], (integer)llFrand((float)llGetListLength([ "OWNER overwhelmed TARGET with radiant energy", "OWNER burned TARGET with radiation", "OWNER blasted TARGET with destructive light", "OWNER made TARGET glow. Briefly", "OWNER irradiated TARGET fatally", "OWNER's energy discharge claimed TARGET", "OWNER seared TARGET with pure energy", "OWNER outshone TARGET fatally", "OWNER exposed TARGET to lethal radiation", "OWNER annihilated TARGET with raw energy" ]))); + else if(type == DAMAGE_TYPE_SLASHING) reason = llList2String([ "OWNER cut TARGET down", "OWNER carved TARGET apart", "OWNER opened TARGET up", "OWNER's blade met TARGET. Once was enough", "OWNER sliced through TARGET", "OWNER left TARGET with fatal cuts", "OWNER shredded TARGET", "OWNER tore TARGET apart", "OWNER made short work of TARGET", "OWNER showed TARGET the sharp end" ], (integer)llFrand((float)llGetListLength([ "OWNER cut TARGET down", "OWNER carved TARGET apart", "OWNER opened TARGET up", "OWNER's blade met TARGET. Once was enough", "OWNER sliced through TARGET", "OWNER left TARGET with fatal cuts", "OWNER shredded TARGET", "OWNER tore TARGET apart", "OWNER made short work of TARGET", "OWNER showed TARGET the sharp end" ]))); + else if(type == DAMAGE_TYPE_SONIC) reason = llList2String([ "OWNER blasted TARGET with sonic force", "OWNER collapsed TARGET's senses", "OWNER overwhelmed TARGET with pressure waves", "OWNER's sonic blast proved fatal for TARGET", "OWNER deafened TARGET permanently", "OWNER rocked TARGET with acoustic weaponry", "OWNER weaponized sound against TARGET", "OWNER won the volume war against TARGET", "OWNER shattered TARGET with sonic energy", "OWNER concussed TARGET with a sonic assault" ], (integer)llFrand((float)llGetListLength([ "OWNER blasted TARGET with sonic force", "OWNER collapsed TARGET's senses", "OWNER overwhelmed TARGET with pressure waves", "OWNER's sonic blast proved fatal for TARGET", "OWNER deafened TARGET permanently", "OWNER rocked TARGET with acoustic weaponry", "OWNER weaponized sound against TARGET", "OWNER won the volume war against TARGET", "OWNER shattered TARGET with sonic energy", "OWNER concussed TARGET with a sonic assault" ]))); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = llList2String([ "OWNER emotionally devastated TARGET", "OWNER collapsed TARGET's morale entirely", "OWNER broke TARGET's spirit", "OWNER dealt critical emotional damage to TARGET", "OWNER crushed TARGET's confidence", "OWNER won the psychological war against TARGET", "OWNER destroyed TARGET's morale", "OWNER applied pressure TARGET never recovered from", "OWNER left TARGET mentally defeated", "OWNER made sure TARGET took the loss personally" ], (integer)llFrand((float)llGetListLength([ "OWNER emotionally devastated TARGET", "OWNER collapsed TARGET's morale entirely", "OWNER broke TARGET's spirit", "OWNER dealt critical emotional damage to TARGET", "OWNER crushed TARGET's confidence", "OWNER won the psychological war against TARGET", "OWNER destroyed TARGET's morale", "OWNER applied pressure TARGET never recovered from", "OWNER left TARGET mentally defeated", "OWNER made sure TARGET took the loss personally" ]))); + else if(type == 100) reason = llList2String([ "OWNER's treatment killed TARGET", "OWNER's medical intervention proved fatal to TARGET", "OWNER flatlined TARGET during treatment", "OWNER gave TARGET one treatment too many", "OWNER's battlefield medicine failed TARGET catastrophically", "OWNER's bedside manner killed TARGET", "OWNER healed TARGET into the afterlife", "OWNER's medkit betrayed TARGET's trust", "OWNER's treatment proved terminal for TARGET", "OWNER medically neutralized TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER's treatment killed TARGET", "OWNER's medical intervention proved fatal to TARGET", "OWNER flatlined TARGET during treatment", "OWNER gave TARGET one treatment too many", "OWNER's battlefield medicine failed TARGET catastrophically", "OWNER's bedside manner killed TARGET", "OWNER healed TARGET into the afterlife", "OWNER's medkit betrayed TARGET's trust", "OWNER's treatment proved terminal for TARGET", "OWNER medically neutralized TARGET" ]))); + else if(type == 101) reason = llList2String([ "OWNER repaired TARGET into catastrophic failure", "OWNER's maintenance proved unsurvivable for TARGET", "OWNER's field repairs ended TARGET", "OWNER performed rapid unscheduled disassembly on TARGET", "OWNER pushed TARGET beyond operational limits", "OWNER's repair attempt finished TARGET off", "OWNER turned maintenance into a kill", "OWNER's adjustments collapsed TARGET's systems", "OWNER overclocked TARGET to destruction", "OWNER rendered TARGET permanently nonfunctional" ], (integer)llFrand((float)llGetListLength([ "OWNER repaired TARGET into catastrophic failure", "OWNER's maintenance proved unsurvivable for TARGET", "OWNER's field repairs ended TARGET", "OWNER performed rapid unscheduled disassembly on TARGET", "OWNER pushed TARGET beyond operational limits", "OWNER's repair attempt finished TARGET off", "OWNER turned maintenance into a kill", "OWNER's adjustments collapsed TARGET's systems", "OWNER overclocked TARGET to destruction", "OWNER rendered TARGET permanently nonfunctional" ]))); + else if(type == 102) reason = llList2String([ "OWNER blew TARGET apart", "OWNER caught TARGET in the blast radius", "OWNER introduced TARGET to high explosives", "OWNER fed TARGET a grenade", "OWNER detonated TARGET", "OWNER's explosives outran TARGET", "OWNER erased TARGET with a blast", "OWNER's detonation shredded TARGET", "OWNER reduced TARGET to debris", "OWNER's ordnance found TARGET standing too close" ], (integer)llFrand((float)llGetListLength([ "OWNER blew TARGET apart", "OWNER caught TARGET in the blast radius", "OWNER introduced TARGET to high explosives", "OWNER fed TARGET a grenade", "OWNER detonated TARGET", "OWNER's explosives outran TARGET", "OWNER erased TARGET with a blast", "OWNER's detonation shredded TARGET", "OWNER reduced TARGET to debris", "OWNER's ordnance found TARGET standing too close" ]))); + else if(type == 103) reason = llList2String([ "OWNER crushed TARGET", "OWNER flattened TARGET completely", "OWNER compressed TARGET into failure", "OWNER pinned and crushed TARGET", "OWNER turned pressure into a weapon against TARGET", "OWNER folded TARGET like field equipment", "OWNER reduced TARGET to scrap", "OWNER compacted TARGET", "OWNER buried TARGET under overwhelming pressure", "OWNER ran a compression test on TARGET. TARGET failed" ], (integer)llFrand((float)llGetListLength([ "OWNER crushed TARGET", "OWNER flattened TARGET completely", "OWNER compressed TARGET into failure", "OWNER pinned and crushed TARGET", "OWNER turned pressure into a weapon against TARGET", "OWNER folded TARGET like field equipment", "OWNER reduced TARGET to scrap", "OWNER compacted TARGET", "OWNER buried TARGET under overwhelming pressure", "OWNER ran a compression test on TARGET. TARGET failed" ]))); + else if(type == 104) reason = llList2String([ "OWNER punched through TARGET's armor", "OWNER penetrated TARGET with anti-armor fire", "OWNER defeated TARGET with AP rounds", "OWNER proved TARGET's armor was a suggestion", "OWNER cracked TARGET open with anti-vehicle fire", "OWNER's penetrators went straight through TARGET", "OWNER turned TARGET's armor into a liability", "OWNER gutted TARGET with heavy ordnance", "OWNER landed a clean anti-armor kill on TARGET", "OWNER won the armor check against TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER punched through TARGET's armor", "OWNER penetrated TARGET with anti-armor fire", "OWNER defeated TARGET with AP rounds", "OWNER proved TARGET's armor was a suggestion", "OWNER cracked TARGET open with anti-vehicle fire", "OWNER's penetrators went straight through TARGET", "OWNER turned TARGET's armor into a liability", "OWNER gutted TARGET with heavy ordnance", "OWNER landed a clean anti-armor kill on TARGET", "OWNER won the armor check against TARGET" ]))); + else if(type == -100) reason = llList2String([ "OWNER confirmed TARGET as 'probably hostile'", "OWNER eliminated TARGET with outstanding target identification skills", "OWNER won the team's internal conflict against TARGET", "OWNER revealed to TARGET that the IFF system was decorative", "OWNER engaged the nearest available lifeform: TARGET", "OWNER delivered allied fire to TARGET. Emphasis on allied", "OWNER mistook TARGET for a tactical opportunity", "OWNER killed TARGET with an aggressive interpretation of teamwork", "OWNER secured a friendly kill on TARGET. Mission unclear", "OWNER taught TARGET not to stand between a squadmate and bad decisions", "OWNER demonstrated why positive identification matters. TARGET objected briefly", "OWNER greeted TARGET with extremely friendly burst fire", "OWNER's suppressive fire caught TARGET non-suppressed", "OWNER showed TARGET a safety selector permanently set to optimism", "OWNER found TARGET faster than the enemy did", "OWNER introduced TARGET to friendly fire", "OWNER secured an enemy-adjacent kill on TARGET", "OWNER promoted TARGET to hostile", "OWNER carried the enemy team by killing TARGET", "OWNER won an internal faction dispute against TARGET", "OWNER treated TARGET like hostile armor", "OWNER killed TARGET with unwavering confidence and limited accuracy", "OWNER's line of fire met TARGET head-on", "OWNER killed TARGET after TARGET challenged basic firearm safety", "OWNER defended their muzzle space from TARGET", "OWNER shot TARGET, who appeared suddenly and unhelpfully", "OWNER's burst fire awarded TARGET a prize for crossing it", "OWNER eliminated TARGET, who selected poor timing as a lifestyle", "OWNER hit TARGET with rounds clearly intended for somebody else", "OWNER's bullets met TARGET halfway", "OWNER solved a tactical problem that TARGET inserted themselves into", "OWNER killed TARGET after TARGET performed unauthorized bullet interception", "OWNER was already shooting exactly where TARGET peeked", "OWNER dropped TARGET, who entered the doorway at maximum inconvenience", "OWNER engaged the enemy while TARGET was hugging it", "OWNER's suppressive fire was suppressed by TARGET's positioning", "OWNER demonstrated why you don't cross the firing lane. TARGET volunteered", "OWNER secured a friendly kill after TARGET sprinted through active gunfire", "OWNER penalized TARGET for performing close-quarters teamwork incorrectly", "OWNER shot TARGET, who materialized directly in the optic picture", "OWNER failed a trigger discipline test administered by TARGET at point-blank range", "OWNER accidentally fulfilled TARGET's request for covering fire", "OWNER charged TARGET full price for using the firing lane as a shortcut", "OWNER killed TARGET, who confused suppressive fire with navigational guidance", "OWNER confirmed TARGET located the line of fire by standing in it", "OWNER eliminated TARGET after TARGET ignored several visible warning signs", "OWNER intervened in TARGET's attempt at advanced teamwork without situational awareness", "OWNER shot TARGET, who committed aggressively to being in the way", "OWNER's ballistic calculations gained an unexpected variable: TARGET", "OWNER's aim was fine. TARGET's pathfinding was not", "OWNER cleared TARGET out from between themselves and success", "OWNER killed TARGET after TARGET made themselves tactically unavoidable" ], (integer)llFrand((float)llGetListLength([ "OWNER confirmed TARGET as 'probably hostile'", "OWNER eliminated TARGET with outstanding target identification skills", "OWNER won the team's internal conflict against TARGET", "OWNER revealed to TARGET that the IFF system was decorative", "OWNER engaged the nearest available lifeform: TARGET", "OWNER delivered allied fire to TARGET. Emphasis on allied", "OWNER mistook TARGET for a tactical opportunity", "OWNER killed TARGET with an aggressive interpretation of teamwork", "OWNER secured a friendly kill on TARGET. Mission unclear", "OWNER taught TARGET not to stand between a squadmate and bad decisions", "OWNER demonstrated why positive identification matters. TARGET objected briefly", "OWNER greeted TARGET with extremely friendly burst fire", "OWNER's suppressive fire caught TARGET non-suppressed", "OWNER showed TARGET a safety selector permanently set to optimism", "OWNER found TARGET faster than the enemy did", "OWNER introduced TARGET to friendly fire", "OWNER secured an enemy-adjacent kill on TARGET", "OWNER promoted TARGET to hostile", "OWNER carried the enemy team by killing TARGET", "OWNER won an internal faction dispute against TARGET", "OWNER treated TARGET like hostile armor", "OWNER killed TARGET with unwavering confidence and limited accuracy", "OWNER's line of fire met TARGET head-on", "OWNER killed TARGET after TARGET challenged basic firearm safety", "OWNER defended their muzzle space from TARGET", "OWNER shot TARGET, who appeared suddenly and unhelpfully", "OWNER's burst fire awarded TARGET a prize for crossing it", "OWNER eliminated TARGET, who selected poor timing as a lifestyle", "OWNER hit TARGET with rounds clearly intended for somebody else", "OWNER's bullets met TARGET halfway", "OWNER solved a tactical problem that TARGET inserted themselves into", "OWNER killed TARGET after TARGET performed unauthorized bullet interception", "OWNER was already shooting exactly where TARGET peeked", "OWNER dropped TARGET, who entered the doorway at maximum inconvenience", "OWNER engaged the enemy while TARGET was hugging it", "OWNER's suppressive fire was suppressed by TARGET's positioning", "OWNER demonstrated why you don't cross the firing lane. TARGET volunteered", "OWNER secured a friendly kill after TARGET sprinted through active gunfire", "OWNER penalized TARGET for performing close-quarters teamwork incorrectly", "OWNER shot TARGET, who materialized directly in the optic picture", "OWNER failed a trigger discipline test administered by TARGET at point-blank range", "OWNER accidentally fulfilled TARGET's request for covering fire", "OWNER charged TARGET full price for using the firing lane as a shortcut", "OWNER killed TARGET, who confused suppressive fire with navigational guidance", "OWNER confirmed TARGET located the line of fire by standing in it", "OWNER eliminated TARGET after TARGET ignored several visible warning signs", "OWNER intervened in TARGET's attempt at advanced teamwork without situational awareness", "OWNER shot TARGET, who committed aggressively to being in the way", "OWNER's ballistic calculations gained an unexpected variable: TARGET", "OWNER's aim was fine. TARGET's pathfinding was not", "OWNER cleared TARGET out from between themselves and success", "OWNER killed TARGET after TARGET made themselves tactically unavoidable" ]))); + else if(type == 105) reason = llList2String([ "OWNER deprived TARGET of oxygen", "OWNER suffocated TARGET", "OWNER left TARGET without breathable air", "OWNER ran TARGET out of air", "OWNER choked the life out of TARGET", "OWNER removed breathing from TARGET's options", "OWNER turned atmosphere into a privilege TARGET didn't have", "OWNER revoked TARGET's oxygen access", "OWNER asphyxiated TARGET", "OWNER reminded TARGET that breathing was mandatory. Too late" ], (integer)llFrand((float)llGetListLength([ "OWNER deprived TARGET of oxygen", "OWNER suffocated TARGET", "OWNER left TARGET without breathable air", "OWNER ran TARGET out of air", "OWNER choked the life out of TARGET", "OWNER removed breathing from TARGET's options", "OWNER turned atmosphere into a privilege TARGET didn't have", "OWNER revoked TARGET's oxygen access", "OWNER asphyxiated TARGET", "OWNER reminded TARGET that breathing was mandatory. Too late" ]))); + else reason = "OWNER killed TARGET with " + DamageTypeAsNoun(type) + " damage"; + } + + reason = llReplaceSubString(reason, "TARGET", "secondlife:///app/agent/" + (string)target + "/inspect", 0); + reason = llReplaceSubString(reason, "OWNER", "secondlife:///app/agent/" + (string)owner + "/inspect", 0); + return reason; +} + + +// Applies anti-armor damage in Combat2, with compatibility fallback to LBA (Listen Based Armor) +DamageArmor(key target, float damage) +{ + // Has Combat2 health, apply ANTI_ARMOR type + if(llGetHealth(target) > 0) + { + llDamage(target, damage, 104); + // llOwnerSay("Applied " + (string)damage + " anti-armor damage to " + (string)target); + } + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = ((integer)("0x" + llGetSubString(llMD5String(target, 0), 0, 3))); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + // llOwnerSay("Applied " + (string)damage + " LBA damage to " + (string)target); + } + } +} + +// Alternatively, if you have llDetectedType available, such as from a collision or a sensor, +// use that instead of llGetHealth as you can check for DAMAGEABLE which is more accurate +// as it signifies the object can actually process damage +DamageArmorDetected(integer index, float damage) +{ + key target = llDetectedKey(index); + integer detectedType = llDetectedType(index); + + // Can process damage, so apply ANTI_ARMOR type + if(detectedType & DAMAGEABLE) llDamage(target, damage, 104); + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = ((integer)("0x" + llGetSubString(llMD5String(target, 0), 0, 3))); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + } + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl new file mode 100644 index 0000000000..a386be24de --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's inverse-kinematics.lsl (include/NexiiLSL/LICENSE): negative +// numbers as macros, and what they are compared with. +#include "NexiiLSL/inverse-kinematics.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl new file mode 100644 index 0000000000..3b903b5b2a --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl @@ -0,0 +1,65 @@ +// NexiiLSL's inverse-kinematics.lsl (include/NexiiLSL/LICENSE): negative +// numbers as macros, and what they are compared with. + + + + + + +// Simple inverse kinematics via trigonemetry +TrigIK( + vector position, // relative position, only uses x (forward/back) and z (up/down) + float joint0, // First joint length + float joint1, // Second joint length + vector axis, // Axis that the joint rotates on + integer complementary // Whether to use complementary angle on result +) { + position.y = 0; + float distance = llVecMag(position); + + // If we can't reach it, just extend out + if(distance > joint0 + joint1) + { + IKr = -1; + IK1 = llEuler2Rot(-axis * llAtan2(position.z, position.x)); + IK2 = ZERO_ROTATION; + return; + } + + float distanceSq = distance * distance; + float joint0Sq = joint0 * joint0; + float joint1Sq = joint1 * joint1; + float x = (joint0Sq - joint1Sq + distanceSq) / (2 * joint0 * distance); + float y = (joint0Sq + joint1Sq - distanceSq) / (2 * joint0 * joint1); + float q0 = llAtan2(position.z, position.x); + + // Too close + if(x > 1 || x < -1 || y > 1 || y < -1) + { + IKr = -2; + IK1 = llEuler2Rot(-axis *( q0)); + IK2 = ZERO_ROTATION; + return; + } + + // There's two different ways to reach the same point + float q1; float q2; + if(complementary) + { + q1 = q0 - llAcos(x); + q2 = PI - llAcos(y); + } + else + { + q1 = q0 + llAcos(x); + q2 = PI + llAcos(y); + } + + IKr = 1; + IK1 = llEuler2Rot(-axis * q1); + IK2 = llEuler2Rot(-axis * q2); +} +// Return values: +integer IK; // IK results; IK_OUT_OF_REACH, IK_TOO_CLOSE, IK_SUCCESS +rotation IK1; // First joint rotation +rotation IK2; // Second joint rotation diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl new file mode 100644 index 0000000000..932d7bd274 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl @@ -0,0 +1,27 @@ +// NexiiLSL's linkset.lsl as the repository has it now (include/NexiiLSL/ +// LICENSE; include/linkset.lsl is an older copy), used as its comments +// show: statements for an argument, over several lines of their own. +#include "NexiiLSL/linkset.lsl" + +integer Foot; +integer Leg; + +default +{ + state_entry() + { + { + LinksetScan( + if(linkName == "Foot") Foot = link; + else if(linkName == "Leg") Leg = link; + ); + } + { + key object = llGetKey(); + ObjectLinksetScan(object, + if(linkName == "Turret") llOwnerSay("turret " + (string)linkKey); + else if(linkName == "Barrel") llOwnerSay("barrel " + (string)linkKey); + ); + } + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl new file mode 100644 index 0000000000..ca948682c8 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl @@ -0,0 +1,220 @@ +// NexiiLSL's linkset.lsl as the repository has it now (include/NexiiLSL/ +// LICENSE; include/linkset.lsl is an older copy), used as its comments +// show: statements for an argument, over several lines of their own. + +//////////////////////////////////////////////////////////////////////////////////////////// +/// Linkset scanning functions +/// These functions return linkset numbers + +// Returns a list of linkset numbers from a prim name +list LinksByName(string needle) { + list needles; + integer link = 1; + integer prims = llGetNumberOfPrims(); + for(; link <= prims; ++link) if(llGetLinkName(link) == needle) needles += link; + return needles; +} + +// Returns a linkset number based on the name +integer LinkByName(string needle) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) if(llGetLinkName(prims) == needle) return prims; + return FALSE; +} + +// Converts a list of prim names to a list of linkset numbers +list LinksetList(list needles) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) { + integer pointer = llListFindList(needles, [llGetLinkName(prims)]); + if(~pointer) needles = llListReplaceList(needles, [prims], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate + + + + + + + +/* Usage: +LinksetScan( + if(linkName == "Foot") Foot = link; + else if(linkName == "Leg") Leg = link; + else if(linkName == "Torso") Torso = link; + else if(linkName == "Head") Head = link; +); +*/ + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// Remote Object Linkset scanning variants +/// These functions return keys instead of linkset numbers + +// Returns a list of linkset keys from a prim name +list ObjectLinksByName(key object, string needle) { + list needles; + integer link = 1; + integer prims = llGetObjectPrimCount(object); + for(; link <= prims; ++link) + { + key linkKey = llGetObjectLinkKey(object, link); + string linkName = llKey2Name(linkKey); + if(linkName == needle) needles += linkKey; + } + return needles; +} + +// Returns a linkset key based on the name +key ObjectLinkByName(key object, string needle) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) + { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + if(linkName == needle) return linkKey; + } + return NULL_KEY; +} + +// Converts a list of prim names to a list of linkset keys +list ObjectLinksetList(key object, list needles) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + integer pointer = llListFindList(needles, [linkName]); + if(~pointer) needles = llListReplaceList(needles, [linkKey], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate + + + + + + + + +/* Usage: +ObjectLinksetScan(object, + if(linkName == "Turret") ... + else if(linkName == "Barrel") ... +); +*/ + + +// Scans an object for a list of sitting avatars +list ObjectLinksetSittingAvatars(key object) { + list details = llGetObjectDetails(object, [OBJECT_PRIM_COUNT, OBJECT_SIT_COUNT]); + integer prims = llList2Integer(details, 0); + integer sitters = llList2Integer(details, 1); + + list agents; + integer index = prims + 1; + integer count = prims + sitters; + for(; index <= count; ++index) agents += llGetObjectLinkKey(object, index); + + return agents; +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// "Linkset Resources" use Linkset Data to store/cache an array of linkset numbers in a compact format +/// The usecase is typically for reusable prims/meshes in a linkset such as in a HUD or user interface +/// For example for dynamically rendering markers, text or for data visualisation + +// Sets up a Linkset Resource on the key kv, finding prims in the linkset whose name exactly matches pattern to put into the resource +LinksetResourceSetup(string kv, string pattern) { + string links; + integer iterator = llGetNumberOfPrims(); + while(iterator --> 0) + { + string name = llGetLinkName(1 + iterator); + if(name == pattern) links = llChar(iterator) + links; + } + llLinksetDataWrite(kv, links); +} + +// Pulls out a linkset number from the resource +integer LinksetResourceReserve(string kv) { + string links = llLinksetDataRead(kv); + if(links == "") return FALSE; + llLinksetDataWrite(kv, llDeleteSubString(links, 0, 0)); + return 1 + llOrd(links, 0); +} + +// Releases a linkset number back into the resource +LinksetResourceRelease(string kv, integer link) { + llLinksetDataWrite(kv, llChar(link - 1) + llLinksetDataRead(kv)); +} + +// Resets the prim params for a Linkset Resource, e.g. to reset a bunch of reusable prims to a default state out of sight +LinksetResourceReset(string kv, list reset) { + list params; + string links = llLinksetDataRead(kv); + integer iterator = llStringLength(links); + while(iterator --> 0) + { + params += [PRIM_LINK_TARGET, 1 + llOrd(links, iterator)] + reset; + if(llGetFreeMemory() < 1500) { llSetLinkPrimitiveParamsFast(0, params); params = []; } + } + if(params) llSetLinkPrimitiveParamsFast(0, params); +} + +integer LinksetResourceLookup(string kv, integer index) { + string links = llLinksetDataRead(kv); + if(links) return 1 + llOrd(links, index); + else return FALSE; +} + + + +// These two functions build upon linkset resources at a higher level, so that you can trade linkset numbers between resources +// For example to have one linkset resource of unused reusable prims, and another for reusable prims you are currently using to render in a UI + +// Takes out a linkset number from the "Pool" resource and puts it into the "Used" resource +// You can flip the kv's to do the reverse +integer LinksetResourceUse(string kvPool, string kvUsed) { + string links = llLinksetDataRead(kvPool); + if(links == "") return FALSE; + llLinksetDataWrite(kvPool, llDeleteSubString(links, 0, 0)); + integer link = 1 + llOrd(links, 0); + llLinksetDataWrite(kvUsed, llChar(link - 1) + llLinksetDataRead(kvUsed)); + return link; +} + +// Frees up all the linkset numbers by moving all from the "Used" resource and puts them back into "Pool" resource +LinksetResourceFreeAll(string kvPool, string kvUsed) { + llLinksetDataWrite(kvPool, llLinksetDataRead(kvUsed) + llLinksetDataRead(kvPool)); + llLinksetDataWrite(kvUsed, ""); +} + +// TODO: A way to move a specific linkset number from a "Used" resource into the "Pool" resource in one function +// LinksetResourceFree(string kvPool, string kvUsed, integer link) {...} + +integer Foot; +integer Leg; + +default +{ + state_entry() + { + { + integer link = llGetNumberOfPrims(); do { string linkName = llGetLinkName(link); if(linkName == "Foot") Foot = link; else if(linkName == "Leg") Leg = link; } while(--link > 1);; + } + { + key object = llGetKey(); + integer link = llGetObjectPrimCount(object); do { key linkKey = llGetObjectLinkKey(object, link); string linkName = llKey2Name(linkKey); if(linkName == "Turret") llOwnerSay("turret " + (string)linkKey); else if(linkName == "Barrel") llOwnerSay("barrel " + (string)linkKey); } while(--link > 1);; + } + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl new file mode 100644 index 0000000000..386a87525b --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl @@ -0,0 +1,6 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with what +// its #ifdefs ask for defined: an #ifdef indented inside a function, and +// inside a list over several lines. +#define RLSD_LINK_START 3 +#define RLSD_DISPLAY +#include "NexiiLSL/remote-linkset-data.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl new file mode 100644 index 0000000000..77d2ab5c19 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with +// nothing its #ifdefs ask for defined: each left out. +#include "NexiiLSL/remote-linkset-data.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl new file mode 100644 index 0000000000..11ba47edbf --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl @@ -0,0 +1,107 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with +// nothing its #ifdefs ask for defined: each left out. + +// Implementation of a public Remote Linkset Data storage system, similar in practice to llLinksetDataRead but on a remote object +// Gotcha is that this works entirely through prim params (names, descriptions and/or hover text to store data) +// This does mean that this it is mostly suitable for a server object to hold the remote linkset data + +// PRIM_NAME -- 63 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_DESC -- 127 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_TEXT -- 254 bytes, full UTF-8 range -- best for storing the raw values chunked across multiple prims +// -- Warning: Any PRIM_TEXT with unicode in range 0x1-0x1F via llChar corrupts the object from being rezzed from inventory +// Storage capacity is dependent on the number of prims in the linkset +// Values are divided by 254 bytes per child prim's PRIM_TEXT, but it is not guaranteed to utilise capacity fully +// as data is reserved in 254 byte chunks for each `name:value` pair, so e.g. if you have a linkset of 61 prims for example (where 1 prim is the root): +// - 60x`name:value` pairs if value is <= 254 bytes, or +// - 30x`name:value` pairs if values are up to 254*2 bytes. + +string RemoteLinksetDataRead(key object, string name) +{ + if(object == llGetKey() || object == NULL_KEY) return llLinksetDataRead("Remote." + name); + + // TODO: Lookup table on root prim? + integer link = 1; + @remoteReadSearch; + key prim = llGetObjectLinkKey(object, ++link); + if(prim == NULL_KEY) return ""; + string primName = llKey2Name(prim); + if(primName != name) jump remoteReadSearch; + + string value; + @remoteReadLoop; + value += (string)llGetObjectDetails(prim, [OBJECT_TEXT]); + prim = llGetObjectLinkKey(object, ++link); + primName = llKey2Name(prim); + if(primName == name) jump remoteReadLoop; + + return value; +} + +integer RemoteLinksetDataWrite(key object, string name, string value) +{ + if(object != llGetKey() && object != NULL_KEY) return 6; // Currently only allow writing from own object to keep the implementation straightforward + + // TODO: Check many prims we have left available for writing (any prims free at all + chunk/prim for full value) + + // We mirror the data locally for rebuilds + integer result = llLinksetDataWrite("Remote." + name, value); + if(result) return result; + + RebuildRemoteLinksetData(); + + return LINKSETDATA_OK; +} + + + +// INTERNAL -- Rebuild entire remote dataset, this is not efficient but it keeps the implementation robust, avoiding defragmentation issues +RebuildRemoteLinksetData() +{ + list params; + + list names = llLinksetDataFindKeys("^Remote\\.", 0, 0); + integer link = 2; + + + + integer index; + integer count = llGetListLength(names); + vector lastColor; + for(; index < count; ++index) + { + string name = llList2String(names, index); + string value = llLinksetDataRead(name); + name = llDeleteSubString(name, 0, 6); + + vector color = (vector)llLinksetDataRead("RemoteDebug." + name); + while(color == ZERO_VECTOR || color * lastColor > 0.8) llLinksetDataWrite("RemoteDebug." + name, ((string)(color = llVecNorm()))); + lastColor = color; + + integer chunks = llCeil(llStringLength(value) / 254.); + while(chunks --> 0) + { + string chunk = llGetSubString(value, 0, 253); + value = llDeleteSubString(value, 0, 253); + params += [ + PRIM_LINK_TARGET, link++, + PRIM_NAME, name, + PRIM_TEXT, chunk, <0, 0, 0>, 0 + + + + + ]; + } + } + + // TODO: Lookup table on root prim? + + llSetLinkPrimitiveParamsFast(LINK_ALL_CHILDREN, [ + PRIM_NAME, "-", + PRIM_TEXT, "", <0, 0, 0>, 0 + + + + + ] + params); +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl new file mode 100644 index 0000000000..9265051c1d --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl @@ -0,0 +1,110 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with what +// its #ifdefs ask for defined: an #ifdef indented inside a function, and +// inside a list over several lines. + + + +// Implementation of a public Remote Linkset Data storage system, similar in practice to llLinksetDataRead but on a remote object +// Gotcha is that this works entirely through prim params (names, descriptions and/or hover text to store data) +// This does mean that this it is mostly suitable for a server object to hold the remote linkset data + +// PRIM_NAME -- 63 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_DESC -- 127 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_TEXT -- 254 bytes, full UTF-8 range -- best for storing the raw values chunked across multiple prims +// -- Warning: Any PRIM_TEXT with unicode in range 0x1-0x1F via llChar corrupts the object from being rezzed from inventory +// Storage capacity is dependent on the number of prims in the linkset +// Values are divided by 254 bytes per child prim's PRIM_TEXT, but it is not guaranteed to utilise capacity fully +// as data is reserved in 254 byte chunks for each `name:value` pair, so e.g. if you have a linkset of 61 prims for example (where 1 prim is the root): +// - 60x`name:value` pairs if value is <= 254 bytes, or +// - 30x`name:value` pairs if values are up to 254*2 bytes. + +string RemoteLinksetDataRead(key object, string name) +{ + if(object == llGetKey() || object == NULL_KEY) return llLinksetDataRead("Remote." + name); + + // TODO: Lookup table on root prim? + integer link = 1; + @remoteReadSearch; + key prim = llGetObjectLinkKey(object, ++link); + if(prim == NULL_KEY) return ""; + string primName = llKey2Name(prim); + if(primName != name) jump remoteReadSearch; + + string value; + @remoteReadLoop; + value += (string)llGetObjectDetails(prim, [OBJECT_TEXT]); + prim = llGetObjectLinkKey(object, ++link); + primName = llKey2Name(prim); + if(primName == name) jump remoteReadLoop; + + return value; +} + +integer RemoteLinksetDataWrite(key object, string name, string value) +{ + if(object != llGetKey() && object != NULL_KEY) return 6; // Currently only allow writing from own object to keep the implementation straightforward + + // TODO: Check many prims we have left available for writing (any prims free at all + chunk/prim for full value) + + // We mirror the data locally for rebuilds + integer result = llLinksetDataWrite("Remote." + name, value); + if(result) return result; + + RebuildRemoteLinksetData(); + + return LINKSETDATA_OK; +} + + + +// INTERNAL -- Rebuild entire remote dataset, this is not efficient but it keeps the implementation robust, avoiding defragmentation issues +RebuildRemoteLinksetData() +{ + list params; + + list names = llLinksetDataFindKeys("^Remote\\.", 0, 0); + integer link = 2; + + link = 3; + + integer index; + integer count = llGetListLength(names); + vector lastColor; + for(; index < count; ++index) + { + string name = llList2String(names, index); + string value = llLinksetDataRead(name); + name = llDeleteSubString(name, 0, 6); + + vector color = (vector)llLinksetDataRead("RemoteDebug." + name); + while(color == ZERO_VECTOR || color * lastColor > 0.8) llLinksetDataWrite("RemoteDebug." + name, ((string)(color = llVecNorm()))); + lastColor = color; + + integer chunks = llCeil(llStringLength(value) / 254.); + while(chunks --> 0) + { + string chunk = llGetSubString(value, 0, 253); + value = llDeleteSubString(value, 0, 253); + params += [ + PRIM_LINK_TARGET, link++, + PRIM_NAME, name, + PRIM_TEXT, chunk, <0, 0, 0>, 0 + + + , PRIM_COLOR, 0, color, 1 + + ]; + } + } + + // TODO: Lookup table on root prim? + + llSetLinkPrimitiveParamsFast(LINK_ALL_CHILDREN, [ + PRIM_NAME, "-", + PRIM_TEXT, "", <0, 0, 0>, 0 + + + , PRIM_COLOR, 0, <0, 0, 0>, 1 + + ] + params); +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl new file mode 100644 index 0000000000..cee21ddef3 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's scripts/rez-queue-check.lsl (include/NexiiLSL/LICENSE): a +// whole script, which includes app-uri.lsl by the repository's path. +#include "NexiiLSL/scripts/rez-queue-check.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl new file mode 100644 index 0000000000..6f4da18dc8 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl @@ -0,0 +1,264 @@ +// NexiiLSL's scripts/rez-queue-check.lsl (include/NexiiLSL/LICENSE): a +// whole script, which includes app-uri.lsl by the repository's path. + + + + + + + + + + + + + +float start; +float end; +integer hasQueue; +integer hasLargeQueue; +list tracked; + +integer lastCount; +integer tick; + +checkAttachments() +{ + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + string animation = llGetAnimation(agent); + string legacyName = llKey2Name(agent); // Ghosted avatars have an empty string + string displayName = llGetDisplayName(agent); // May not always be non-empty string? + string userName = llGetUsername(agent); // May not always be non-empty string? + list attachments = llGetAttachedList(agent); + + if(animation == "" + || animation == "Init" + || legacyName == "" + || displayName == "" + || userName == "" + || llGetListLength(attachments) == 0) + jump continue; // Do not consider agent if entering/exiting region, logging, teleporting or ghosted + + list prevAttachments = llJson2List(llLinksetDataRead(agent + "_attachments")); + list attached; + list detached; + + // Attached + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + integer pointer = llListFindList(prevAttachments, [attachment]); + if(pointer == -1) + { + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + attached += attachment; + } + } + + // Detached + index = llGetListLength(prevAttachments); + while(index --> 0) + { + string attachment = llList2String(prevAttachments, index); + integer pointer = llListFindList(attachments, [(key)attachment]); + if(pointer == -1) + { + // llLinksetDataDelete(agent + "_" + attachment); + detached += attachment; + } + } + + // Cache + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + + + //if(hasQueue || hasLargeQueue) + //{ + //string message; + integer a = llGetListLength(attached); + integer d = llGetListLength(detached); + /*if(a && d) message = agentAboutURI(agent) + " attached " + string(a) + " and detached " + string(d) + " items"; + else if(a) message = agentAboutURI(agent) + " attached " + string(a) + " items"; + else if(d) message = agentAboutURI(agent) + " detached " + string(d) + " items";*/ + + if(a) tracked += "secondlife:///app/agent/" + ((string)(agent)) + "/about"; + + /*while(a --> 0) + { + string attachment = llList2String(attached, a); + message += "\n+ " + objectURI(attachment); + }*/ + + while(d --> 0) + { + string attachment = llList2String(detached, d); + // message += "\n- " + llLinksetDataRead(attachment + "_name"); + llLinksetDataDelete(attachment + "_name"); + // llLinksetDataDelete(agent + "_" + attachment); + } + + //if(message) llOwnerSay(message); + //} + + @continue; + } +} + +announce(string message) { + string name = llGetObjectName(); + llSetObjectName(llGetRegionName()); + + //llSay(PUBLIC_CHANNEL, message); + + //* + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + key agent = llList2Key(agents, iterator); + llRegionSayTo(agent, PUBLIC_CHANNEL, message); + } + //*/ + + llSetObjectName(name); +} + + +default +{ + state_entry() + { + llSetText("", <1,1,1>, 1); + llLinksetDataDeleteFound("_(attachments|name)$", ""); + + list agents = llGetAgentList(AGENT_LIST_REGION, []); + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + integer iterator = lastCount = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + } + } + + llOwnerSay("Initialise Rez Queue Checker (Mem " + llGetSubString((string)(llGetFreeMemory() / 1024.0), 0, -6) + "KB / LSD " + llGetSubString((string)(llLinksetDataAvailable() / 1024.0), 0, -6) + "KB)"); + // llOwnerSay((string)); + + llSetTimerEvent(20/45.); + checkAttachments(); + } + + timer() + { + if(llGetTime() > 60*60*12) llResetScript(); + + ++tick; + + integer count = llGetRegionAgentCount(); + if(lastCount != count) + { + lastCount = count; + list lastAgents = llJson2List(llLinksetDataRead("agents")); + list agents = llGetAgentList(AGENT_LIST_REGION, []); + list added; + list removed; + while(count --> 0) + { + string agent = llList2String(agents, count); + integer pointer = llListFindList(lastAgents, [agent]); + if(pointer == -1) + { + added += "secondlife:///app/agent/" + ((string)(agent)) + "/about"; + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + } + } + + count = llGetListLength(lastAgents); + while(count --> 0) + { + string agent = llList2String(lastAgents, count); + integer pointer = llListFindList(agents, [(key)agent]); + if(pointer == -1) + { + removed += "secondlife:///app/agent/" + ((string)(agent)) + "/about"; + llLinksetDataDeleteFound("^" + agent, ""); + } + } + + //if(removed) llOwnerSay("Removed: " + llList2CSV(removed)); + //if(added) llOwnerSay("Added: " + llList2CSV(added)); + + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + } + + float now = llGetTime(); + float downloads = llGetSimStats(SIM_STAT_ASSET_DOWNLOADS); + float uploads = llGetSimStats(SIM_STAT_ASSET_UPLOADS); + + // if(downloads > 0.5 || uploads > 0.5) + if(downloads > 0.01 || uploads > 0.01) + { + if(!hasQueue) + { + //llOwnerSay("Downloads pending!"); + hasQueue = TRUE; + end = start = now; + } + + else if(hasQueue) + { + end = now; + if(downloads > 0.9 && /*now - end > 1.5 &&*/ !hasLargeQueue) + { + hasLargeQueue = TRUE; + tracked = []; + announce("Rez queue detected!"); + } + } + } + + else if(hasLargeQueue) + { + float delta = end - start; + start = end = 0; + checkAttachments(); + string message = "Rez queue ended after " + llGetSubString((string)delta, 0, -5) + "s"; + if(tracked) message += " due to " + llDumpList2String(tracked, ", "); + announce(message); + hasQueue = hasLargeQueue = FALSE; + tracked = []; + } + + else if(hasQueue) + { + float delta = end - start; + checkAttachments(); + //llOwnerSay("Pending over after " + llGetSubString((string)delta, 0, -5) + "s"); + hasQueue = FALSE; + } + + else if(!(tick % 45)) checkAttachments(); + } + + changed(integer change) + { + if(change & CHANGED_REGION) llResetScript(); + } + + attach(key avatar) + { + llResetScript(); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl new file mode 100644 index 0000000000..989b330c49 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's scripts/roundabout.lsl (include/NexiiLSL/LICENSE): a whole +// script, with strings over several lines, which includes time.lsl. +#include "NexiiLSL/scripts/roundabout.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl new file mode 100644 index 0000000000..a370f03829 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl @@ -0,0 +1,354 @@ +// NexiiLSL's scripts/roundabout.lsl (include/NexiiLSL/LICENSE): a whole +// script, with strings over several lines, which includes time.lsl. + +/* +The Magic Roundabout + +This script is designed to help with the dilemma of assigning one script in the region +as the main controller responsible for loading remote data such as from experience storage +or HTTP requests and then having to distribute any loaded data to other subordinate scripts + +Make sure the script is set to no modify so people can't peek at the private key used +-- that is the only piece of info that must truly be kept secret + +Because the script sends along a signature in the listener messages, it means you are +limited to 852 bytes max (1024 - 172) +*/ + + +/* + Compares two timestamps per ISO 8601 format "YYYY-MM-DDThh:mm:ss.ff..fZ" + Returns -1 if a < b, else 1 if a > b else 0 if same + + You can comment/cut out parts of the function if you only care about + date checks or only want fast time checks +*/ +integer CompareTimestamps(string a, string b) +{ + integer aYear = (integer)llGetSubString(a, 0, 3); + integer bYear = (integer)llGetSubString(b, 0, 3); + if(aYear < bYear) return -1; else if(aYear > bYear) return 1; + integer aMonth = (integer)llGetSubString(a, 5, 6); + integer bMonth = (integer)llGetSubString(b, 5, 6); + if(aMonth < bMonth) return -1; else if(aMonth > bMonth) return 1; + integer aDay = (integer)llGetSubString(a, 8, 9); + integer bDay = (integer)llGetSubString(b, 8, 9); + if(aDay < bDay) return -1; else if(aDay > bDay) return 1; + integer aHour = (integer)llGetSubString(a, 11, 12); + integer bHour = (integer)llGetSubString(b, 11, 12); + if(aHour < bHour) return -1; else if(aHour > bHour) return 1; + integer aMinute = (integer)llGetSubString(a, 14, 15); + integer bMinute = (integer)llGetSubString(b, 14, 15); + if(aMinute < bMinute) return -1; else if(aMinute > bMinute) return 1; + float aSecond = (float)llGetSubString(a, 17, -2); + float bSecond = (float)llGetSubString(b, 17, -2); + if(aSecond < bSecond) return -1; else if(aSecond > bSecond) return 1; + return 0; +} + +// Pure string version of https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Unix(string stamp) +{ + integer year = (integer)llGetSubString(stamp, 0, 3); + integer month = (integer)llGetSubString(stamp, 5, 6); + integer day = (integer)llGetSubString(stamp, 8, 9); + integer hours = (integer)llGetSubString(stamp, 11, 12); + integer minutes = (integer)llGetSubString(stamp, 14, 15); + integer seconds = (integer)llGetSubString(stamp, 17, -2); + + year -= 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + + month = ~-month; + day = ~-day; + + integer days = (integer)(year * 365.25 + 0.25) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + day; + + return ( + days * 86400 + + hours * 3600 + + minutes * 60 + + seconds + ); +} + +// Compact Check +integer TimestampIsBefore(string a, string b) +{ + if((integer)llGetSubString(a, 8, 9) < (integer)llGetSubString(b, 8, 9)) return TRUE; + if((integer)llGetSubString(a, 11, 12) < (integer)llGetSubString(b, 11, 12)) return TRUE; + if((integer)llGetSubString(a, 14, 15) < (integer)llGetSubString(b, 14, 15)) return TRUE; + if((float)llGetSubString(a, 17, -2) < (float)llGetSubString(b, 17, -2)) return TRUE; + return FALSE; +} + +integer TimestampCompare(string a, string b) +{ + integer aDate = (integer)(llGetSubString(a, 0, 3) + llGetSubString(a, 5, 6) + llGetSubString(a, 8, 9)); + integer bDate = (integer)(llGetSubString(b, 0, 3) + llGetSubString(b, 5, 6) + llGetSubString(b, 8, 9)); + if(aDate < bDate) return -1; + else if(aDate > bDate) return 1; + float aTime = (float)(llGetSubString(a, 11, 12) + llGetSubString(a, 14, 15) + llGetSubString(a, 17, -1)); + float bTime = (float)(llGetSubString(b, 11, 12) + llGetSubString(b, 14, 15) + llGetSubString(b, 17, -1)); + llOwnerSay("aTime: " + (string)aTime + "; bTime: " + (string)bTime); + if(aTime < bTime) return -1; + else if(aTime > bTime) return 1; + return 0; +} + +// Milliseconds since beginning of month for timestamp +integer Timestamp2Millisec(string stamp) +{ + return (integer)llGetSubString(stamp, 8, 9) * 86400000 + // Days + (integer)llGetSubString(stamp, 11, 12) * 3600000 + // Hours + (integer)llGetSubString(stamp, 14, 15) * 60000 + // Minutes + llRound(((float)llGetSubString(stamp, 17, -2) * 1000.0)) // Seconds.Milliseconds + - 617316353; // Offset to fit between [-617316353,2147483547] +} + +// Seconds since timestamp epoch, per https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Seconds(string stamp) +{ + list parts = llParseString2List(stamp, ["-", "T", ":", "."], []); + integer year = llList2Integer(parts, 0) - 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + integer month = ~-llList2Integer(parts, 1); + return 86400 * (((integer)(year * 365.25 + 0.25)) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + + (~-llList2Integer(parts, 2))) + + llList2Integer(parts, 3) * 3600 + + llList2Integer(parts, 4) * 60 + + llList2Integer(parts, 5); +} + + + +string PrivateKey = "-----BEGIN RSA PRIVATE KEY-----\nreplace with private key\n-----END RSA PRIVATE KEY-----" + +; +string PublicKey = "-----BEGIN PUBLIC KEY-----\nreplace with public key\n-----END PUBLIC KEY-----" + +; +float LastPingPong = -60.0; +float LastController = -60.0; + +key Controller = NULL_KEY; +list Subordinates; + +integer Tick; + +default +{ + state_entry() + { + llMessageLinked(LINK_SET, -100, "init", ""); + llLinksetDataWrite("Roundabout", "init"); + state subordinate; + } +} + +state subordinate +{ + state_entry() + { + llListen(-100, "", "", ""); + llSetTimerEvent(10.0); + + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(-100, signature + payload); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + LastPingPong = llGetTime(); + + // Respond to ping with pong + text = "pong," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + } + + else if(message == "pong") + { + LastPingPong = llGetTime(); + + // Was controller, but might have been reset + if(identifier == Controller) Controller = NULL_KEY; + + // Add to subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer == -1) Subordinates += identifier; + } + + else if(message == "controller") + { + LastController = llGetTime(); + + // We just initialised and there is a controller out there + // Announce to other scripts we are subordinate and ready to request data + if(Controller == NULL_KEY) + { + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, -100, "subordinate", ""); + Controller = identifier; + } + + // New controller + else if(identifier != Controller) Controller = identifier; + + // Same controller + else if(identifier == Controller); + + // Remove from subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer != -1) Subordinates = llDeleteSubList(Subordinates, pointer, pointer); + } + + // Pass along any other messages from controller into linkset + else if(identifier == Controller) + { + llMessageLinked(LINK_SET, -100, text, identifier); + } + } + + timer() + { + ++Tick; + float time = llGetTime(); + + // We lost our controller + if(time - LastController > 60.0 || (Controller != NULL_KEY && llKey2Name(Controller) == "")) + { + // Promote oldest subordinate to controller + key subordinate = llGetKey(); + list checks = [subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME]))]; + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) != "") checks += [ + subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME])) + ]; + } + checks = llListSortStrided(checks, 2, 1, TRUE); + subordinate = llList2Key(checks, 0); + + // Promote ourselves + if(subordinate == llGetKey()) state controller; + + // Promote subodinate to controller + else Controller = subordinate; + } + + // Ping everyone + else if(time - LastPingPong > 30.0) + { + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(-100, signature + payload); + } + + // Garbage collection on list + if(!(Tick % 16)) + { + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) == "") + { + llOwnerSay("Lost subordinate " + (string)subordinate); + Subordinates = llDeleteSubList(Subordinates, iterator, iterator); + } + } + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == -100 + 1 && Controller != NULL_KEY) + { + // Pass along messages from subordinate to controller + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSayTo(Controller, -100, signature + text); + } + } + + state_exit() + { + // We don't track subordinates outside this state + Subordinates = []; + } +} + + +state controller +{ + state_entry() + { + string text = "controller," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + + llLinksetDataWrite("Roundabout", "controller"); + llMessageLinked(LINK_SET, -100, "controller", ""); + llListen(-100, "", "", ""); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + // Respond to ping with controller + text = "controller," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + } + else if(message == "pong"); // Ignore + else if(message == "controller") + { + // Huh? There was another controller? + LastController = llGetTime(); + Controller = identifier; + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, -100, "subordinate", ""); + state subordinate; + } + + // Pass along any other messages from subordinates into linkset + else + { + llMessageLinked(LINK_SET, -100, text, identifier); + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == -100 + 1) + { + // Pass along messages from controller to subordinates + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + } + } +} \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl new file mode 100644 index 0000000000..c1d9a02a7f --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl @@ -0,0 +1,18 @@ +// NexiiLSL's library for packing strings small (include/NexiiLSL/ +// LICENSE), string-compression.lsl -- its name kept off the first line, +// which would ask the golden run for compression: an include of an +// include by the path the repository gives it, #defines +// inside blocks, a name used before the #define that makes it a macro +// (`RANGE`, left as it is, as C leaves it), and a cast with its line's +// brackets unbalanced around it. Its constants it does not use itself are +// used in a state after it. +#include "NexiiLSL/string-compression.lsl" + +state limits +{ + state_entry() + { + list limits = [MAXIMUM, UTF8_1B_MAX, UTF8_2B_MAX, UTF8_3B_MAX, UTF8_4B_MAX, + UTF8_SURROGATE_START, UTF8_SURROGATE_END, UTF8_NONCHARACTER_1, UTF8_NONCHARACTER_2]; + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl new file mode 100644 index 0000000000..0e591490ff --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl @@ -0,0 +1,272 @@ +// NexiiLSL's library for packing strings small (include/NexiiLSL/ +// LICENSE), string-compression.lsl -- its name kept off the first line, +// which would ask the golden run for compression: an include of an +// include by the path the repository gives it, #defines +// inside blocks, a name used before the #define that makes it a macro +// (`RANGE`, left as it is, as C leaves it), and a cast with its line's +// brackets unbalanced around it. Its constants it does not use itself are +// used in a state after it. + + + + + +// For displaying ordinal numbers like 1st, 2nd, 3rd, 20th, 21st, 22nd, etc. +string nth(integer value) +{ + value = value % 10; + if(value == 1) return "st"; + if(value == 2) return "nd"; + if(value == 3) return "rd"; + return "th"; +} + +// Returns the number of bytes a string would take up in UTF-8 encoding +integer stringBytes(string str) { + integer bytes; + integer length = llStringLength(str); + integer iterator; + for(; iterator < length; ++iterator) + { + integer byte = llOrd(str, iterator); + if(byte <= 0x7F) bytes += 1; + else if(byte <= 0x07FF) bytes += 2; + else if(byte <= 0xFFFF) bytes += 3; + else bytes += 4; + } + return bytes; +} + +// Significantly faster +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + + +// From https://wiki.secondlife.com/wiki/Efficient_Hex +string bits2nybbles(integer bits) { + integer lsn; // least significant nybble + string nybbles = ""; + do + nybbles = llGetSubString("0123456789abcdef", lsn = (bits & 0xF), lsn) + nybbles; + while (bits = (0xfffFFFF & (bits >> 4))); + return nybbles; +} + + +string AttachmentPointAsName(integer point) +{ + if(point == ATTACH_HEAD) return "Skull"; + if(point == ATTACH_NOSE) return "Nose"; + if(point == ATTACH_MOUTH) return "Mouth"; + if(point == ATTACH_FACE_TONGUE) return "Tongue"; + if(point == ATTACH_CHIN) return "Chin"; + if(point == ATTACH_FACE_JAW) return "Jaw"; + if(point == ATTACH_LEAR) return "Left Ear"; + if(point == ATTACH_REAR) return "Right Ear"; + if(point == ATTACH_FACE_LEAR) return "Alt Left Ear"; + if(point == ATTACH_FACE_REAR) return "Alt Right Ear"; + if(point == ATTACH_LEYE) return "Left Eye"; + if(point == ATTACH_REYE) return "Right Eye"; + if(point == ATTACH_FACE_LEYE) return "Alt Left Eye"; + if(point == ATTACH_FACE_REYE) return "Alt Right Eye"; + if(point == ATTACH_NECK) return "Neck"; + if(point == ATTACH_LSHOULDER) return "Left Shoulder"; + if(point == ATTACH_RSHOULDER) return "Right Shoulder"; + if(point == ATTACH_LUARM) return "L Upper Arm"; + if(point == ATTACH_RUARM) return "R Upper Arm"; + if(point == ATTACH_LLARM) return "L Lower Arm"; + if(point == ATTACH_RLARM) return "R Lower Arm"; + if(point == ATTACH_LHAND) return "Left Hand"; + if(point == ATTACH_RHAND) return "Right Hand"; + if(point == ATTACH_LHAND_RING1) return "Left Ring Finger"; + if(point == ATTACH_RHAND_RING1) return "Right Ring Finger"; + if(point == ATTACH_LWING) return "Left Wing"; + if(point == ATTACH_RWING) return "Right Wing"; + if(point == ATTACH_CHEST) return "Chest"; + if(point == ATTACH_LEFT_PEC) return "Left Pec"; + if(point == ATTACH_RIGHT_PEC) return "Right Pec"; + if(point == ATTACH_BELLY) return "Stomach"; + if(point == ATTACH_BACK) return "Spine"; + if(point == ATTACH_TAIL_BASE) return "Tail Base"; + if(point == ATTACH_TAIL_TIP) return "Tail Tip"; + if(point == ATTACH_AVATAR_CENTER) return "Avatar Center"; + if(point == ATTACH_PELVIS) return "Pelvis"; + if(point == ATTACH_GROIN) return "Groin"; + if(point == ATTACH_LHIP) return "Left Hip"; + if(point == ATTACH_RHIP) return "Right Hip"; + if(point == ATTACH_LULEG) return "L Upper Leg"; + if(point == ATTACH_RULEG) return "R Upper Leg"; + if(point == ATTACH_RLLEG) return "R Lower Leg"; + if(point == ATTACH_LLLEG) return "L Lower Leg"; + if(point == ATTACH_LFOOT) return "Left Foot"; + if(point == ATTACH_RFOOT) return "Right Foot"; + if(point == ATTACH_HIND_LFOOT) return "Left Hind Foot"; + if(point == ATTACH_HIND_RFOOT) return "Right Hind Foot"; + if(point == ATTACH_HUD_CENTER_2) return "HUD Center 2"; + if(point == ATTACH_HUD_TOP_RIGHT) return "HUD Top Right"; + if(point == ATTACH_HUD_TOP_CENTER) return "HUD Top"; + if(point == ATTACH_HUD_TOP_LEFT) return "HUD Top Left"; + if(point == ATTACH_HUD_CENTER_1) return "HUD Center"; + if(point == ATTACH_HUD_BOTTOM_LEFT) return "HUD Bottom Left"; + if(point == ATTACH_HUD_BOTTOM) return "HUD Bottom"; + if(point == ATTACH_HUD_BOTTOM_RIGHT) return "HUD Bottom Right"; + return (string)point; +} + + + + + + + + +// Available bytes for UTF8 packing: + + + + + + + + + + +default +{ + state_entry() + { + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned integer (0 to UTF8_4B_MAX-1) + // Can save a byte or two but importantly provides array-like access + { + integer input = 0x10FFFF - 1; + + string compressed = llChar(1 + input); + + integer output = llOrd(compressed, 0) - 1; + + llOwnerSay( + "Compact int: " + (string)input + " => " + compressed + " => " + (string)output + "\n" + + "Compression " + (string)stringBytes((string)input) + "b => " + (string)stringBytes(compressed) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned float + { + + float quantizer = (0x7F - 1) / RANGE; + + float input = 1337.256; + + string compressed = llChar(1 + ((integer)(input * quantizer)); + + float output = (llOrd(compressed, 0) - 1) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact signed float + { + + float quantizer = (0x7F - 1) / 2048.0 / 2; + + float input = 1337.256; + + string compressed = llChar(1 + ((integer)(quantizer + input * quantizer)); + + float output = ((llOrd(compressed, 0) - 1) - quantizer) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact vector (region coords <0,0,0> to <256,256,4096>) + { + // Quantization from 0-4096 / 0-256 + float xyquant = (0x7F - 1) / 4096.; + float zquant = (0x7F - 1) / 256.; + vector a = <250.525, 128.128128, 3059.999>; + + // Compress + string b = llChar(1 + ((integer)(a.x * xyquant))) + + llChar(1 + ((integer)(a.y * xyquant))) + + llChar(1 + ((integer)(a.z * zquant))); + + // Decompress + vector c = < + (llOrd(b, 0) - 1) / xyquant, + (llOrd(b, 1) - 1) / xyquant, + (llOrd(b, 2) - 1) / zquant + >; + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact keys (36 -> 8 chars); Keys are really 16 byte values, so we can pack them tightly + // There is probably a better way to do this to fit into the 4 byte UTF-8 chars and deserialise it inline + { + key a = "64064c89-25aa-008b-cfe7-0c34e28ae523"; + + // Compress + string b = llChar(((integer)("0x" + llGetSubString(a, 0, 3)))) + + llChar(((integer)("0x" + llGetSubString(a, 4, 7)))) + + llChar(((integer)("0x" + llGetSubString(a, 9, 12)))) + + llChar(((integer)("0x" + llGetSubString(a, 14, 17)))) + + llChar(((integer)("0x" + llGetSubString(a, 19, 22)))) + + llChar(((integer)("0x" + llGetSubString(a, 24, 27)))) + + llChar(((integer)("0x" + llGetSubString(a, 28, 31)))) + + llChar(((integer)("0x" + llGetSubString(a, 32, 35)))); + + // Decompress + key c = bits2nybbles(llOrd(b, 0)) + bits2nybbles(llOrd(b, 1)) + + "-" + bits2nybbles(llOrd(b, 2)) + "-" + bits2nybbles(llOrd(b, 3)) + + "-" + bits2nybbles(llOrd(b, 4)) + "-" + bits2nybbles(llOrd(b, 5)) + + bits2nybbles(llOrd(b, 6)) + bits2nybbles(llOrd(b, 7)); + + llOwnerSay( + "Compact Key (36 -> 8): " + (string)a + " => '" + b + "' => " + (string)c + "\n" + + "Compression " + (string)stringBytes(a) + "b => " + (string)stringBytes(b) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Compact rotations (<-1,-1,-1,-1> to <1,1,1,1>) + { + float quantizer = 0x10FFFF / 2; + vector a = <-2, 27, 120>; + rotation b = llEuler2Rot(a * DEG_TO_RAD); + + // Compress + string c = llChar(1 + ((integer)(quantizer + b.x * quantizer))) + + llChar(1 + ((integer)(quantizer + b.y * quantizer))) + + llChar(1 + ((integer)(quantizer + b.z * quantizer))) + + llChar(1 + ((integer)(quantizer + b.s * quantizer))); + + // Decompress + rotation d = < + ((llOrd(c, 0) - 1) - quantizer) / quantizer, + ((llOrd(c, 1) - 1) - quantizer) / quantizer, + ((llOrd(c, 2) - 1) - quantizer) / quantizer, + ((llOrd(c, 3) - 1) - quantizer) / quantizer + >; + + vector e = llRot2Euler(d) * RAD_TO_DEG; + + llOwnerSay( + "Compact rot: " + (string)a + " => " + (string)b + " => '" + c + "' => " + (string)d + " => " + (string)e + "\n" + + "Compression " + (string)stringBytes(llList2CSV([a,b])) + "b => " + (string)stringBytes(c) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + + + } +} +state limits +{ + state_entry() + { + list limits = [2048.0, 0x7F, 0x07FF, 0xFFFF, 0x10FFFF, + 0xD800, 0xDFFF, 0xFFFE, 0xFFFF]; + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl new file mode 100644 index 0000000000..fc6df1d1e0 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl @@ -0,0 +1,12 @@ +// NexiiLSL's scripts/sync-lsd.lsl (include/NexiiLSL/LICENSE): a whole +// script, its numbers as macros; the one it does not use itself is used +// in a state after it. +#include "NexiiLSL/scripts/sync-lsd.lsl" + +state events +{ + state_entry() + { + llOwnerSay((string)EVENT_MESSAGE); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl new file mode 100644 index 0000000000..e93fd3d8f3 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl @@ -0,0 +1,644 @@ +// NexiiLSL's scripts/sync-lsd.lsl (include/NexiiLSL/LICENSE): a whole +// script, its numbers as macros; the one it does not use itself is used +// in a state after it. + +/* + Synchronised Linkset Data + + This script synchronises specific linkset data keys across the region that match a prefix, per the config. + + On initialisation, rezzing or changing region it does a full reset/synchronisation with the full + tracked dataset (per scopes) of the network and then simply listens for changes as they propagate across the region. + + Each object can be configured to only sync specific prefixes to optimise bandwidth and processing. + + If you want to persist data across reboots (e.g. if syncing data across temp attach / intermittent objects), + make sure to establish a permanent server object that holds data. +*/ + +// SETTINGS BELOW CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +/// Security +// We use RSA signatures to verify secure communications +string privateKey = "-----REPLACE WITH RSA PRIVATE KEY-----"; +string publicKey = "-----REPLACE WITH RSA PUBLIC KEY-----"; +// We use a protected linkset data key to prevent spoofing due to extracted scripts +// You only need this when using this script in no-mod attachments, e.g. experience temp attach +string protectedName = "SYNC_LSD_PROTECTED"; +string protectedKey = "-----REPLACE WITH SECRET-----"; + +/// Scope prefixes of linkset data keys to synchronise +list scopes = [ + "Test_", + "Foo_", + "Bar_" +]; +// Corresponding listener channels for each scope above +list scopeChannels = [ + 204505, + 204506, + 204507 +]; + +// SETTINGS ABOVE CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Link Message Numbers + + + +// Event Types Enumeration + + + + + + + + +// Event Bit flags + + + + +list candidates = [/* key object, string scope */]; +list pending = [/* integer action, string name */]; +list verified = [/* key object */]; +list buffer = [/* string name, string value */]; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +integer inScope(string name) { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) return TRUE; + } + return FALSE; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + +sendData(key target, integer channel, integer eventType, string name, string value) { + integer nameLength = llStringLength(name); + integer available = 1023 - (3 + nameLength); + integer total = getStringBytes(value); + integer isASCII = (total == llStringLength(value)); // Prefer llStringLength as its fastest + + // Fragment the update across multiple messages + if(total > available) + { + eventType = eventType | 64; + while(value) + { + string fragment = ""; + integer width = 0; + while(total > 0) + { + string part = llGetSubString(value, 0, 250); + integer bytes; + if(isASCII) bytes = llStringLength(part); + else bytes = getStringBytes(part); + + if(width + bytes <= available) + { + value = llDeleteSubString(value, 0, 250); + width += bytes; + total -= bytes; + fragment += part; + } + else jump breakFragment; + } + @breakFragment; + + if(value == "") eventType = eventType | 128; + + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + fragment; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } + } + + // Direct message + else + { + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + value; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Initialise by pinging all and get latest dataset from network +default +{ + state_entry() + { + if(llLinksetDataReadProtected(protectedName, protectedKey) == "") + { + llDetachFromAvatar(); + llDie(); + return; + } + + llMessageLinked(LINK_SET, 1, "", ""); + + // Reset scoped data, setup listeners and ping everyone + integer iterator = llGetListLength(scopes); + string signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + integer signatureLength = llStringLength(signature); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + llLinksetDataDeleteFound("^" + prefix, ""); + integer channel = llList2Integer(scopeChannels, iterator); + llListen(channel, "", "", ""); + llRegionSay(channel, llChar(1) + llChar(signatureLength) + signature + prefix); + } + + // Timeout from pongs + llSetTimerEvent(0.5); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & 63; + integer eventFlags = header & ~63; + + if(eventType == 2) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Don't add to verified list yet, check scope first + + // Refresh timeout + llSetTimerEvent(FALSE); + llSetTimerEvent(0.5); + + // Add to candidates if scope matches + integer isVerified = (llListFindList(verified, [identifier]) != -1); + list peerScope = llParseString2List(llGetSubString(message, 2 + signatureLength, -1), [llChar(1)], []); + integer peerIterator = llGetListLength(peerScope); + while(peerIterator --> 0) + { + string peerPrefix = llList2String(peerScope, peerIterator); + + // Check if each peer prefix matches any of our scopes + integer ownIterator = llGetListLength(scopes); + while(ownIterator --> 0) + { + string ownPrefix = llList2String(scopes, ownIterator); + + // Phase 1: Check if peer prefix is wider, shorter or exact match + // Is peer prefix wider? + if(llSubStringIndex(peerPrefix, ownPrefix) != 0) + { + // Is peer prefix shorter? + if(llSubStringIndex(ownPrefix, peerPrefix) != 0) + { + // Peer prefix doesnt match our scope to track, so skip it + jump nextPeerPrefix; + } + } + + if(!isVerified) + { + // Mark as verified + integer iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + verified += identifier; + isVerified = TRUE; + } + + // Phase 2: Compare with candidates; Check if already exact match or is shorter, otherwise delete any wider prefixes + integer candidatesIterator = llGetListLength(candidates); + while(candidatesIterator > 0) + { + candidatesIterator -= 2; + string candidatePrefix = llList2String(candidates, candidatesIterator + 1); + + if(peerPrefix == candidatePrefix) + jump nextPeerPrefix; // Already have this candidate; + + // Is candidate shorter than peer? + else if(llSubStringIndex(peerPrefix, candidatePrefix) == 0) + jump nextPeerPrefix; // Skip adding peer as candidate as we already have a better match + + // Is candidate wider than peer? + else if(llSubStringIndex(candidatePrefix, peerPrefix) == 0) + // Delete candidate as peer is a better match + candidates = llDeleteSubList(candidates, candidatesIterator, candidatesIterator + 1); + } + + // Phase 3: Add peer as candidate + candidates += [identifier, peerPrefix]; + + @nextPeerPrefix; + } + @nextPeer; + } + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Sync event + if(eventType == 4) + { + // integer action = llOrd(message, 1) - 1; + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + string value = llGetSubString(message, 3 + nameLength, -1); + + if(!inScope(name)) return; + + if(eventFlags & 64) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & 128) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + llLinksetDataWrite(name, value); + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else llLinksetDataWrite(name, value); + } + + else if(eventType == 5) + { + // If no more candidates, move to sync state + if(candidates); else state sync; + + // Pull out the next candidate to sync + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(3) + llDumpList2String(peerScopes, llChar(1)) + ); + } + } + + timer() + { + llSetTimerEvent(FALSE); + + // If no candidates, move directly to sync state + if(candidates); else state sync; + + // Sort candidates by key + candidates = llListSort(candidates, 2, TRUE); + + // Pull out a candidate + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(3) + llDumpList2String(peerScopes, llChar(1)) + ); + } + + state_exit() + { + llMessageLinked(LINK_SET, 2, "", ""); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Idle away, synchronising data as needed +state sync +{ + on_rez(integer param) { llResetScript(); } + changed(integer change) { if(change & CHANGED_REGION) llResetScript(); } + state_entry() + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) llListen(llList2Integer(scopeChannels, iterator), "", "", ""); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & 63; + integer eventFlags = header & ~63; + + if(eventType == 1) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Check if peer prefix matches any of our scopes + integer hasMatch = FALSE; + list matchedScopes; + string peerPrefix = llGetSubString(message, 2 + signatureLength, -1); + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string ownPrefix = llList2String(scopes, iterator); + if(llSubStringIndex(peerPrefix, ownPrefix) == 0) matchedScopes += ownPrefix; + else if(llSubStringIndex(ownPrefix, peerPrefix) == 0) matchedScopes += ownPrefix; + } + if(matchedScopes); else return; + + // Mark as verified + iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + if(llListFindList(verified, [identifier]) == -1) verified += identifier; + + // Respond with pong + signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + signatureLength = llStringLength(signature); + llRegionSayTo(identifier, channel, + llChar(2) + + llChar(signatureLength) + + signature + + llDumpList2String(matchedScopes, llChar(1)) + ); + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Remote linkset data event + if(eventType == 6) + { + integer action = llOrd(message, 1) - 1; + + if(action == LINKSETDATA_UPDATE) + { + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + + if(!inScope(name)) return; + + string value = llGetSubString(message, 3 + nameLength, -1); + + if(eventFlags & 64) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & 128) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else + { + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + } + + else if(action == LINKSETDATA_DELETE) + { + string name = llGetSubString(message, 2, -1); + if(!inScope(name)) return; + if(llLinksetDataDelete(name) == LINKSETDATA_OK) pending += [action, name]; + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + integer prefixLength = llOrd(message, 2); + string prefix = llGetSubString(message, 3, 2 + prefixLength); + // if(!inScope(prefix)) return; -- This is complex as the source may be wider or narrower than our scopes but still have relevant data, hmm... + // For now we cheat a bit because delete found can tell us how many were deleted + // There is however danger for overlap between local and synced data if they have the same names but weren't intended to be synced + + // Escape regex special characters and prepare bucket as a regex pattern + string bucket = llGetSubString(message, 3 + prefixLength, -1); + bucket = llReplaceSubString(bucket, ".", "\\.", 0); + bucket = llReplaceSubString(bucket, "^", "\\^", 0); + bucket = llReplaceSubString(bucket, "$", "\\$", 0); + bucket = llReplaceSubString(bucket, "*", "\\*", 0); + bucket = llReplaceSubString(bucket, "+", "\\+", 0); + bucket = llReplaceSubString(bucket, "?", "\\?", 0); + bucket = llReplaceSubString(bucket, "(", "\\(", 0); + bucket = llReplaceSubString(bucket, ")", "\\)", 0); + bucket = llReplaceSubString(bucket, "{", "\\{", 0); + bucket = llReplaceSubString(bucket, "}", "\\}", 0); + bucket = llReplaceSubString(bucket, "|", "\\|", 0); + bucket = "^" + prefix + "(" + llReplaceSubString(bucket, llChar(1), "|", 0) + ")$"; + integer deleted = llList2Integer(llLinksetDataDeleteFound(bucket, ""), 0); + if(deleted) pending += [action, ""]; + } + + else if(action == LINKSETDATA_RESET) + { + string prefix = llGetSubString(message, 2, -1); + if(!inScope(prefix)) return; + // llLinksetDataReset(); -- Takes out all data even locally scoped, so we convert it to a multi-delete instead + integer deleted = llList2Integer(llLinksetDataDeleteFound("^" + prefix, ""), 0); + if(deleted) pending += [LINKSETDATA_MULTIDELETE, ""]; + } + } + + // TODO: Would this be desirable? Would have to specify a common channel though. The idea would be to piggyback on the secure comms to send messages across + // // Remote link message event + // else if(eventType == EVENT_MESSAGE) + // { + // integer textLength = llOrd(message, 1); + // string text = llGetSubString(message, 2, 1 + textLength); + // string text2 = llGetSubString(message, 2 + textLength, -1); + // llMessageLinked(LINK_SET, MESSAGE_SYNC, text, text2); + // } + + // Full synchronisation with peer + else if(eventType == 3) + { + list requestScopes; + integer requestIterator; + integer requestTotal; + if(llStringLength(message) > 1) + { + requestScopes = llParseString2List(llGetSubString(message, 1, -1), [llChar(1)], []); + requestTotal = llGetListLength(requestScopes); + } + + integer sent = 0; + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string pattern = "^" + llList2String(scopes, iterator); + list names = llLinksetDataFindKeys(pattern, 0, FALSE); // TODO: Change to paginate the results + integer nameIterator = llGetListLength(names); + while(nameIterator --> 0) + { + string name = llList2String(names, nameIterator); + if(requestTotal) + { + for(requestIterator = 0; requestIterator < requestTotal; ++requestIterator) + { + string requestPrefix = llList2String(requestScopes, requestIterator); + if(llSubStringIndex(name, requestPrefix) == 0) jump requestMatch; + } + jump requestSkip; + } + @requestMatch; + + string value = llLinksetDataRead(name); + sendData(identifier, channel, 4, name, value); + if((++sent % 32) == 0) llSleep(4/45.); // Avoid flooding + + @requestSkip; + } + } + + llRegionSayTo(identifier, channel, llChar(5)); + } + } + + linkset_data(integer action, string name, string value) + { + // Skip if this was expected and a change by this script from an external source + if(pending) + { + integer pendingAction = llList2Integer(pending, 0); + string pendingName = llList2String(pending, 1); + if(action == pendingAction) + { + if((action == LINKSETDATA_UPDATE || action == LINKSETDATA_DELETE) && name == pendingName) + { pending = llDeleteSubList(pending, 0, 1); return; } + + else // MULTIDELETE or RESET + { pending = llDeleteSubList(pending, 0, 1); return; } + } + } + + if(action == LINKSETDATA_UPDATE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + return sendData(NULL_KEY, channel, 6, name, value); + } + } + } + + else if(action == LINKSETDATA_DELETE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(6) + llChar(action + 1) + name); + return; + } + } + } + + else if(action == LINKSETDATA_RESET) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(6) + llChar(action + 1) + prefix); + } + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + list deleted = llParseString2List(name, [","], []); + list known; + integer total = llGetListLength(scopes); + integer iterator = total; + while(iterator --> 0) known += ""; + + do { + name = llList2String(deleted, 0); + deleted = llDeleteSubList(deleted, 0, 0); + + iterator = total; + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + name = llDeleteSubString(name, 0, llStringLength(prefix) - 1); + + string bucket = llList2String(known, iterator); + if(bucket) bucket += llChar(1) + name; else bucket += name; + known = llListReplaceList(known, [bucket], iterator, iterator); + } + } + } while(deleted); + + iterator = total; + while(iterator --> 0) + { + string bucket = llList2String(known, iterator); + if(bucket) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(6) + llChar(action + 1) + llChar(llStringLength(prefix)) + prefix + bucket); + // TODO: Check if we need to fragment the bucket across multiple messages + } + } + } + } +} + +state events +{ + state_entry() + { + llOwnerSay((string)7); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl new file mode 100644 index 0000000000..5ff01a28b9 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl @@ -0,0 +1,23 @@ +// NexiiLSL's texture.lsl (include/NexiiLSL/LICENSE): macros whose bodies +// run over several lines, a backslash straight after a comma or an open +// bracket, calls to another of them inside a body over several lines, a +// cast in a body, numbers written .5 and 2., and a name a body takes from +// where it is used (`digit`) rather than from its parameters. +#include "NexiiLSL/texture.lsl" + +default +{ + state_entry() + { + integer digit = 3; + float width = 10; + float height = 20; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 0, TEXTURE_CURSORS, CURSOR(1, 2), 0]); + list repeat = [TEXTURE_REPEAT(1, 2, 64, 128)]; + vector offset = TEXTURE_OFFSET(1, 2, 64, 128); + list coords = [TEXTURE_COORDS(1, 2, 3, 4, 64, 128)]; + list rect = [TEXTURE_RECT(1, 2, 3, 4, 64, 128)]; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 1, TEXTURE_CURSORS, SPRITESHEET(5, 64, 1024), 0]); + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 2, TEXTURE_CURSORS, SPRITESHEET2(5, 32, 16, 512, 256), 0]); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl new file mode 100644 index 0000000000..6f833ab1d2 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl @@ -0,0 +1,104 @@ +// NexiiLSL's texture.lsl (include/NexiiLSL/LICENSE): macros whose bodies +// run over several lines, a backslash straight after a comma or an open +// bracket, calls to another of them inside a body over several lines, a +// cast in a body, numbers written .5 and 2., and a name a body takes from +// where it is used (`digit`) rather than from its parameters. + +// This is the heart of managing texture coordinates, by converting pixel coordinates into SL texture uv coords + + + + + + + + + + + + + + + +// For rendering icons placed neatly into an even grid layout in a square texture resolution + + + + + + + + +// For non-uniform grid layouts as well as textures with non-square aspect ratio + + + + + + + + + +// Compact spritesheet functions by hardcoding resolution and icon sizes + + +// equivalent to #define CURSOR(a, b) TEXTURE_REPEAT(256, 256, 2048., 512.), TEXTURE_OFFSET(, , 2048., 512.) + + + +// Old macros moved from utilities.lsl +// #define TEXTURE_SCALE(w, h) +// #define TEXTURE_OFFSET(w, h, x, y) <(w*.5 + x - RESOLUTION*.5) / RESOLUTION, (RESOLUTION*.5 - (h*.5 + y)) / RESOLUTION, 0> +// #define TEXTURE_SIZE(w, h) w / RESOLUTION*.5, h / RESOLUTION*.5 + + +/* +Texture maths, pixels to coords + +default +{ + state_entry() + { + #define RESOLUTION 1024. + #define FACE 4 + + float width = 181.5; + float height = 116.5; + float x = 30; + float y = 608; + + llScaleTexture( + width / RESOLUTION, + height / RESOLUTION, + FACE + ); + llOffsetTexture( + (width*.5 + x - RESOLUTION*.5) / RESOLUTION, + (RESOLUTION*.5 - (height*.5 + y)) / RESOLUTION, + FACE + ); + llSetScale(< + 0.04, + width/RESOLUTION*.5, + height/RESOLUTION*.5 + >); + } +} + +*/ +default +{ + state_entry() + { + integer digit = 3; + float width = 10; + float height = 20; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 0, "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd", <256/2048., 256/512., 0>, <(128 + (256*1) - 1024)/2048., (256 - 128 + (256*2))/512., 0>, 0]); + list repeat = []; + vector offset = <((1) - ((float)(64))/2) / ((float)(64)), -((2) - ((float)(128))/2) / ((float)(128)), 0>; + list coords = [<3 / ((float)(64)), 4 / ((float)(128)), 0>, <((1) - ((float)(64))/2.) / ((float)(64)), -((2) - ((float)(128))/2.) / ((float)(128)), 0>]; + list rect = [<3 / ((float)(64)), 4 / ((float)(128)), 0>, <((1) + 3/2. - ((float)(64))/2.) / ((float)(64)), -((2) + 4/2. - ((float)(128))/2.) / ((float)(128)), 0>]; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 1, "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd", <64 / ((float)(1024)), 64 / ((float)(1024)), 0>, <((64*.5 + 64 * 5 % (1024/64)) - ((float)(1024))/2.) / ((float)(1024)), -((64*.5 - 64 * 5 / llFloor(1024/64)) - ((float)(1024))/2.) / ((float)(1024)), 0>, 0]); + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 2, "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd", <32 / ((float)(512)), 16 / ((float)(256)), 0>, <((32*.5 + 32 * digit % llFloor(512/32)) - ((float)(512))/2.) / ((float)(512)), -((16*.5 - 16 * digit / llFloor(512/32)) - ((float)(256))/2.) / ((float)(256)), 0>, 0]); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl new file mode 100644 index 0000000000..3c63ee01ee --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl @@ -0,0 +1,12 @@ +// NexiiLSL's utilities.lsl (include/NexiiLSL/LICENSE), its macros called +// where a condition goes. +#include "NexiiLSL/utilities.lsl" + +default +{ + collision_start(integer count) + { + if(isSameOwner(llDetectedKey(0))) llOwnerSay("mine, the " + (string)count + nth(count)); + if(notSameOwner(llDetectedKey(0))) llOwnerSay((string)stringBytes(llDetectedName(0))); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl new file mode 100644 index 0000000000..a79b576ba6 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl @@ -0,0 +1,125 @@ +// NexiiLSL's utilities.lsl (include/NexiiLSL/LICENSE), its macros called +// where a condition goes. + + + + +// For displaying ordinal numbers like 1st, 2nd, 3rd, 20th, 21st, 22nd, etc. +string nth(integer value) +{ + value = value % 10; + if(value == 1) return "st"; + if(value == 2) return "nd"; + if(value == 3) return "rd"; + return "th"; +} + +// Returns the number of bytes a string would take up in UTF-8 encoding +integer stringBytes(string str) { + integer bytes; + integer length = llStringLength(str); + integer iterator; + for(; iterator < length; ++iterator) + { + integer byte = llOrd(str, iterator); + if(byte <= 0x7F) bytes += 1; + else if(byte <= 0x07FF) bytes += 2; + else if(byte <= 0xFFFF) bytes += 3; + else bytes += 4; + } + return bytes; +} + +// Significantly faster +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + + +// From https://wiki.secondlife.com/wiki/Efficient_Hex +string bits2nybbles(integer bits) { + integer lsn; // least significant nybble + string nybbles = ""; + do + nybbles = llGetSubString("0123456789abcdef", lsn = (bits & 0xF), lsn) + nybbles; + while (bits = (0xfffFFFF & (bits >> 4))); + return nybbles; +} + + +string AttachmentPointAsName(integer point) +{ + if(point == ATTACH_HEAD) return "Skull"; + if(point == ATTACH_NOSE) return "Nose"; + if(point == ATTACH_MOUTH) return "Mouth"; + if(point == ATTACH_FACE_TONGUE) return "Tongue"; + if(point == ATTACH_CHIN) return "Chin"; + if(point == ATTACH_FACE_JAW) return "Jaw"; + if(point == ATTACH_LEAR) return "Left Ear"; + if(point == ATTACH_REAR) return "Right Ear"; + if(point == ATTACH_FACE_LEAR) return "Alt Left Ear"; + if(point == ATTACH_FACE_REAR) return "Alt Right Ear"; + if(point == ATTACH_LEYE) return "Left Eye"; + if(point == ATTACH_REYE) return "Right Eye"; + if(point == ATTACH_FACE_LEYE) return "Alt Left Eye"; + if(point == ATTACH_FACE_REYE) return "Alt Right Eye"; + if(point == ATTACH_NECK) return "Neck"; + if(point == ATTACH_LSHOULDER) return "Left Shoulder"; + if(point == ATTACH_RSHOULDER) return "Right Shoulder"; + if(point == ATTACH_LUARM) return "L Upper Arm"; + if(point == ATTACH_RUARM) return "R Upper Arm"; + if(point == ATTACH_LLARM) return "L Lower Arm"; + if(point == ATTACH_RLARM) return "R Lower Arm"; + if(point == ATTACH_LHAND) return "Left Hand"; + if(point == ATTACH_RHAND) return "Right Hand"; + if(point == ATTACH_LHAND_RING1) return "Left Ring Finger"; + if(point == ATTACH_RHAND_RING1) return "Right Ring Finger"; + if(point == ATTACH_LWING) return "Left Wing"; + if(point == ATTACH_RWING) return "Right Wing"; + if(point == ATTACH_CHEST) return "Chest"; + if(point == ATTACH_LEFT_PEC) return "Left Pec"; + if(point == ATTACH_RIGHT_PEC) return "Right Pec"; + if(point == ATTACH_BELLY) return "Stomach"; + if(point == ATTACH_BACK) return "Spine"; + if(point == ATTACH_TAIL_BASE) return "Tail Base"; + if(point == ATTACH_TAIL_TIP) return "Tail Tip"; + if(point == ATTACH_AVATAR_CENTER) return "Avatar Center"; + if(point == ATTACH_PELVIS) return "Pelvis"; + if(point == ATTACH_GROIN) return "Groin"; + if(point == ATTACH_LHIP) return "Left Hip"; + if(point == ATTACH_RHIP) return "Right Hip"; + if(point == ATTACH_LULEG) return "L Upper Leg"; + if(point == ATTACH_RULEG) return "R Upper Leg"; + if(point == ATTACH_RLLEG) return "R Lower Leg"; + if(point == ATTACH_LLLEG) return "L Lower Leg"; + if(point == ATTACH_LFOOT) return "Left Foot"; + if(point == ATTACH_RFOOT) return "Right Foot"; + if(point == ATTACH_HIND_LFOOT) return "Left Hind Foot"; + if(point == ATTACH_HIND_RFOOT) return "Right Hind Foot"; + if(point == ATTACH_HUD_CENTER_2) return "HUD Center 2"; + if(point == ATTACH_HUD_TOP_RIGHT) return "HUD Top Right"; + if(point == ATTACH_HUD_TOP_CENTER) return "HUD Top"; + if(point == ATTACH_HUD_TOP_LEFT) return "HUD Top Left"; + if(point == ATTACH_HUD_CENTER_1) return "HUD Center"; + if(point == ATTACH_HUD_BOTTOM_LEFT) return "HUD Bottom Left"; + if(point == ATTACH_HUD_BOTTOM) return "HUD Bottom"; + if(point == ATTACH_HUD_BOTTOM_RIGHT) return "HUD Bottom Right"; + return (string)point; +} + + + + + + + + +default +{ + collision_start(integer count) + { + if(llGetOwnerKey(llDetectedKey(0)) == llGetOwner()) llOwnerSay("mine, the " + (string)count + nth(count)); + if(llGetOwnerKey(llDetectedKey(0)) != llGetOwner()) llOwnerSay((string)stringBytes(llDetectedName(0))); + } +} diff --git a/indra/cmake/BootstrapVcpkg.cmake b/indra/cmake/BootstrapVcpkg.cmake index 3076020a21..f1c37bb73c 100644 --- a/indra/cmake/BootstrapVcpkg.cmake +++ b/indra/cmake/BootstrapVcpkg.cmake @@ -198,7 +198,8 @@ endfunction() # and a run that installs nothing still costs seconds. It runs only when # something it reads has changed since the install that last ran: the # manifest, the registry configuration, the triplets and what they include, -# the feature list, the triplet, and the vcpkg tool. The root writes the +# the feature list, the triplet, the overlay ports asked for, and the vcpkg +# tool. An overlay port's own files changing is not seen: delete the stamp. The root writes the # stamp once project() has returned, which is after the install succeeded. # AL_VCPKG_INSTALL off leaves the ports to you; deleting the stamp file # forces one install. @@ -235,6 +236,7 @@ if(AL_VCPKG_INSTALL) string(APPEND al_vcpkg_install_key "triplet=${VCPKG_TARGET_TRIPLET};") string(APPEND al_vcpkg_install_key "features=${VCPKG_MANIFEST_FEATURES};") string(APPEND al_vcpkg_install_key "toolchain=${CMAKE_TOOLCHAIN_FILE};") + string(APPEND al_vcpkg_install_key "overlays=${VCPKG_OVERLAY_PORTS};") if(DEFINED VCPKG_INSTALLED_DIR) set(al_vcpkg_installed_dir "${VCPKG_INSTALLED_DIR}") diff --git a/indra/llui/CMakeLists.txt b/indra/llui/CMakeLists.txt index 2ffecb1c5b..d7a84bfffe 100644 --- a/indra/llui/CMakeLists.txt +++ b/indra/llui/CMakeLists.txt @@ -164,6 +164,7 @@ target_sources( alsyntaxgrammar.cpp alsyntaxhighlighter.cpp altabstrip.cpp + altextcarets.cpp altextdiff.cpp altextdocument.cpp altextediting.cpp @@ -387,6 +388,7 @@ target_sources( alsyntaxgrammar.h alsyntaxhighlighter.h altabstrip.h + altextcarets.h altextdiff.h altextdocument.h altextediting.h @@ -500,6 +502,7 @@ if(AL_BUILD_TESTS) al_add_test(alfenwicktree PROJECT llui LIBRARIES ${test_libs}) al_add_test(allinetable PROJECT llui LIBRARIES ${test_libs}) al_add_test(altextdocument UNIT PROJECT llui LIBRARIES ${test_libs}) + al_add_test(altextcarets UNIT PROJECT llui SOURCES altextdocument.cpp LIBRARIES ${test_libs}) al_add_test(altextundo UNIT PROJECT llui SOURCES altextdocument.cpp LIBRARIES ${test_libs}) al_add_test(alnotecarditems UNIT PROJECT llui LIBRARIES ${test_libs}) al_add_test( diff --git a/indra/llui/alcodeeditor.cpp b/indra/llui/alcodeeditor.cpp index 30e7928dfe..f2e35cab2f 100644 --- a/indra/llui/alcodeeditor.cpp +++ b/indra/llui/alcodeeditor.cpp @@ -51,26 +51,11 @@ #include #include +#include #include static LLDefaultChildRegistry::Register r("code_editor"); -namespace -{ - // What is left to do as a scope ends, whichever way it ends. - template - struct AtScopeEnd - { - F f; - ~AtScopeEnd() { f(); } - }; - template - AtScopeEnd atScopeEnd(F f) - { - return AtScopeEnd{ std::move(f) }; - } -} - namespace { const S32 GUTTER_PAD = 6; @@ -267,7 +252,6 @@ void ALCodeEditor::onEdit(const ALTextDocument::Edit& edit) // Decorations and highlights after the edit move along with the text; // the ones it cut into go. - const ALTextRange removed = edit.range.normalised(); mDecorations.apply(edit); for (auto& layer : mHighlights) { @@ -279,23 +263,16 @@ void ALCodeEditor::onEdit(const ALTextDocument::Edit& edit) // is typed is the start of that text; one after the text before it // moves along, since what is typed extends that text. An edit that // takes the position with it takes the inlay. + // A batch's stretches each so, the text between them untouched. mInlays.apply( edit, - [&removed](InlayHint& h, const ALTextDocument::Edit& e) { - if (removed.empty()) - { - if (removed.begin < h.at || (removed.begin == h.at && !h.before)) - { - h.at = e.slidPast(h.at); - } - return true; - } - if (removed.end <= h.at) + [](InlayHint& h, const ALTextDocument::Edit& e) { + if (const std::optional taken = e.replacedAround(h.at); taken && taken->begin < h.at) { - h.at = e.slidPast(h.at); - return true; + return false; } - return h.at <= removed.begin; + h.at = e.placed(h.at, !h.before); + return true; }, [](InlayHint&) {}); // The stops of a snippet or a call being filled in move with the text. @@ -320,13 +297,13 @@ void ALCodeEditor::onEdit(const ALTextDocument::Edit& edit) // an edit that takes it. mAutoClosed.apply( edit, - [&removed](ALTextPos& at, const ALTextDocument::Edit& e) { - if (removed.end <= at) + [](ALTextPos& at, const ALTextDocument::Edit& e) { + if (e.replacedAround(at)) { - at = e.slidPast(at); - return true; + return false; } - return at < removed.begin; + at = e.placed(at); + return true; }, [](ALTextPos&) {}); @@ -783,6 +760,8 @@ S32 ALCodeEditor::heatWidth() const void ALCodeEditor::goToLine(S32 line) { + // One caret there, as goTo leaves. + singleSelection(); setCaret(document().lineStart(line)); } @@ -1718,19 +1697,6 @@ void ALCodeEditor::drawRowExtras(S32 line, S32 row, const LLRect& text, S32 scre } } } - // The places of a text being changed at once, as its stop is. - if (mSnippet.live()) - { - for (const ALSnippetSession::Mirror& mirror : mSnippet.mirrors()) - { - F32 x0, x1; - if (mirror.range.begin.line <= line && line <= mirror.range.end.line && spanOnRow(line, row, mirror.range, x0, x1)) - { - gl_rect_2d(static_cast(left + x0), screen_top, static_cast(left + x1), screen_top - row_h, highlightColor() % alpha); - gl_rect_2d(static_cast(left + x0), screen_top, static_cast(left + x1), screen_top - row_h, mBracketMatchColor.get() % alpha, false); - } - } - } for (S32 i = 0; i < static_cast(mSnippet.stops().size()); ++i) { F32 x0, x1; @@ -2012,9 +1978,44 @@ bool ALCodeEditor::foldAt(S32 line) setCaret(document().lineEnd(chosen->start)); } applyFolds(); + caretsOutOfFolds(); return true; } +void ALCodeEditor::caretsOutOfFolds() +{ + if (!layout().anyHidden()) + { + return; + } + // Each one in what is folded away a caret at the end of the line above + // it that shows -- the fold's own line; those that meet there become + // one. All at once: the main one put alone would open again the folds + // the others are in. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + bool moved = false; + for (size_t i = 0; i < all.size(); ++i) + { + const ALTextRange range = all[i].normalised(); + if (!layout().hidden(range.begin.line) && !layout().hidden(range.end.line)) + { + continue; + } + const S32 hidden = layout().hidden(range.begin.line) ? range.begin.line : range.end.line; + const S32 above = layout().visibleFrom(hidden, -1); + const S32 below = layout().visibleFrom(hidden, 1); + const ALTextPos to = above >= 0 ? document().lineEnd(above) : ALTextPos(below >= 0 ? below : 0, 0); + all[i] = ALTextRange(to, to); + moved = true; + } + if (moved) + { + placeSelections(all, main); + scrollToCaret(); + } +} + bool ALCodeEditor::unfoldAt(S32 line) { if (!folds().unfold(document(), getTabWidth(), line)) @@ -2029,10 +2030,7 @@ void ALCodeEditor::foldAll() { folds().foldAll(document(), getTabWidth()); applyFolds(); - if (layout().hidden(caret().line)) - { - setCaret(document().lineEnd(layout().visibleFrom(caret().line, -1))); - } + caretsOutOfFolds(); } void ALCodeEditor::unfoldAll() @@ -3296,6 +3294,31 @@ bool ALCodeEditor::acceptCompletion() void ALCodeEditor::complete(const Completion& chosen, const ALTextRange& range) { + if (hasOtherSelections()) + { + // What it replaces either side of the main caret, which each other + // caret has replaced where its text reads the same. + const ALTextPos at = caret(); + const std::string before = range.begin < at ? document().text(ALTextRange(range.begin, at)) : std::string(); + const std::string after = at < range.end ? document().text(ALTextRange(at, range.end)) : std::string(); + undoJournal().beginGroup(); + editEach([&](size_t, const ALTextRange& selection) { + ALTextRange over = selection.normalised(); + if (over.empty()) + { + const ALTextPos from(over.end.line, over.end.column - static_cast(before.size())); + const ALTextPos to(over.end.line, over.end.column + static_cast(after.size())); + if (from.column >= 0 && document().text(ALTextRange(from, over.end)) == before && document().text(ALTextRange(over.end, to)) == after) + { + over = ALTextRange(from, to); + } + } + return std::optional(completionAt(chosen, over)); + }); + undoJournal().endGroup(); + setFocus(true); + return; + } setSelection(range); if (!chosen.snippet.empty()) { @@ -3351,6 +3374,60 @@ void ALCodeEditor::complete(const Completion& chosen, const ALTextRange& range) setFocus(true); } +ALTextEditing::Change ALCodeEditor::completionAt(const Completion& chosen, const ALTextRange& over) +{ + ALTextEditing::Change one; + if (!chosen.snippet.empty()) + { + // Indented as the line it goes into, as insertSnippet has it; the + // caret on its first stop. + const std::string& line = document().line(over.begin.line); + const std::string indent = line.substr(0, std::min(line.size(), line.find_first_not_of(" \t"))); + const ALSnippetSession::Expansion expanded = ALSnippetSession::expand(chosen.snippet, over.begin, indent, + ALTextIndent::indentUnit(indent, { getTabWidth(), getSoftTabs() })); + const ALTextRange land = expanded.stops.empty() ? expanded.landing : expanded.stops.front(); + one.replacements.push_back({ over, expanded.text }); + one.selects = true; + one.anchor = land.begin; + one.caret = land.end; + return one; + } + // A function called, as complete() calls it: its first parameter + // chosen, else the caret between its brackets where it takes anything. + const std::string& line = document().line(over.end.line); + const bool called = chosen.kind == ALSyntaxKind::Function && !(over.end.column < static_cast(line.size()) && line[over.end.column] == '('); + if (!called) + { + one.replacements.push_back({ over, chosen.text }); + one.caret = ALTextEditing::endOf(over.begin, chosen.text); + return one; + } + const std::vector names = parameterNames(chosen.detail, chosen.text); + const size_t open = parameterListAt(chosen.detail, chosen.text); + const size_t after = open == std::string::npos ? std::string::npos : chosen.detail.find_first_not_of(' ', open + 1); + const bool takes = open == std::string::npos || after == std::string::npos || chosen.detail[after] != ')'; + std::string call = chosen.text + "("; + for (size_t i = 0; i < names.size(); ++i) + { + call += (i > 0 ? ", " : "") + names[i]; + } + call += ")"; + one.replacements.push_back({ over, call }); + const ALTextPos end = ALTextEditing::endOf(over.begin, call); + if (!names.empty()) + { + const S32 from = over.begin.column + static_cast(chosen.text.size()) + 1; + one.selects = true; + one.anchor = ALTextPos(over.begin.line, from); + one.caret = ALTextPos(over.begin.line, from + static_cast(names.front().size())); + } + else + { + one.caret = takes ? ALTextPos(end.line, end.column - 1) : end; + } + return one; +} + // --- snippets ------------------------------------------------------------------------ void ALCodeEditor::insertSnippet(std::string_view body) @@ -3380,10 +3457,9 @@ void ALCodeEditor::insertSnippet(std::string_view body) setSelection(mSnippet.stops()[0]); } -void ALCodeEditor::syncMirrors(S32 index, bool grouped) +void ALCodeEditor::syncMirrors(S32 index) { - // As one edit, one step to undo -- or part of the key's -- and the - // selection as it was. + // As one edit, one step to undo, and the selection as it was. std::string wanted; const std::vector order = mSnippet.staleMirrors(index, document(), wanted); if (order.empty()) @@ -3398,22 +3474,17 @@ void ALCodeEditor::syncMirrors(S32 index, bool grouped) edits.emplace_back(mSnippet.mirrors()[static_cast(k)].range, wanted); } // All of them as one edit. - if (grouped) - { - undoJournal().beginGroup(); - } + undoJournal().beginGroup(); mSnippet.syncingAll(); editMany(std::move(edits), was.end); mSnippet.syncing(-1); - if (grouped) - { - undoJournal().endGroup(); - } + undoJournal().endGroup(); placeSelection(document().clamp(was.begin), document().clamp(was.end)); afterEdit(); } -std::vector ALCodeEditor::placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most) const +std::vector ALCodeEditor::placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most, + const std::vector& taken_in) const { std::vector out; const ALTextDocument& doc = document(); @@ -3422,23 +3493,12 @@ std::vector ALCodeEditor::placesOf(const std::string& wanted, bool { return out; } - // Taken already: the stop and its mirrors. - const auto taken = [this](const ALTextRange& range) { - for (const ALTextRange& stop : mSnippet.stops()) - { - if (range.begin < stop.end && stop.begin < range.end) - { - return true; - } - } - for (const ALSnippetSession::Mirror& mirror : mSnippet.mirrors()) - { - if (range.begin < mirror.range.end && mirror.range.begin < range.end) - { - return true; - } - } - return false; + // Taken already: any over one of the selections. + const auto taken = [&taken_in](const ALTextRange& range) { + return std::any_of(taken_in.begin(), taken_in.end(), [&range](const ALTextRange& one) { + const ALTextRange r = one.normalised(); + return range.begin < r.end && r.begin < range.end; + }); }; // Each line once, from `from` round to it again. for (S32 step = 0; step <= count && out.size() < most; ++step) @@ -3480,7 +3540,20 @@ bool ALCodeEditor::selectNextOccurrence() { return false; } - setSelection(name); + // And at every other caret, its own. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + for (ALTextRange& one : all) + { + if (one.empty()) + { + const ALTextRange word = identifierAt(one.end); + one = word.empty() ? document().wordAt(one.end) : word; + } + } + all[main] = name; + placeSelections(all, main); + setSelection(selection()); mOccurrenceName = selection().normalised(); return true; } @@ -3488,26 +3561,19 @@ bool ALCodeEditor::selectNextOccurrence() { return false; } - const bool going = mSnippet.live() && mSnippet.stops().size() == 1 && mSnippet.stops()[0] == taken; - if (!going) - { - mSnippet.start({ taken }, taken.end); - mSnippet.setLive(true); - } - // After the last place taken, going round: they are taken in turn. - const ALTextPos from = mSnippet.mirrors().empty() ? taken.end : mSnippet.mirrors().back().range.end; - const std::vector next = placesOf(document().text(taken), taken == mOccurrenceName, from, 1); + // After the main selection, going round, past those taken: the next + // one the main one, so that they are taken in turn. + std::vector all = selectionsInOrder(); + const bool whole = taken == mOccurrenceName; + const std::vector next = placesOf(document().text(taken), whole, taken.end, 1, all); if (next.empty()) { - if (!going) - { - mSnippet.clear(); - } return false; } - mSnippet.addMirror({ 0, next.front() }); - setSelection(taken); - scrollToShow(next.front().begin); + all.push_back(next.front()); + placeSelections(all, all.size() - 1); + setSelection(selection()); + mOccurrenceName = whole ? selection().normalised() : ALTextRange(); return true; } @@ -3524,42 +3590,19 @@ bool ALCodeEditor::changeAllOccurrences() { return false; } - mSnippet.start({ taken }, taken.end); - const std::vector places = placesOf(document().text(taken), whole, taken.end, 1000); - if (places.empty()) + // Every place, the one taken the main selection; the others there + // were let go of. + std::vector all = placesOf(document().text(taken), whole, taken.end, std::numeric_limits::max(), { taken }); + if (all.empty()) { - mSnippet.clear(); return false; } - for (const ALTextRange& place : places) - { - mSnippet.addMirror({ 0, place }); - } - mSnippet.setLive(true); - setSelection(taken); + all.push_back(taken); + placeSelections(all, all.size() - 1); + setSelection(selection()); return true; } -void ALCodeEditor::undo() -{ - // A step back is the text as it was, the places with it: nothing to - // bring up to what the stop holds. - if (mSnippet.live()) - { - clearPlaceholders(); - } - ALTextView::undo(); -} - -void ALCodeEditor::redo() -{ - if (mSnippet.live()) - { - clearPlaceholders(); - } - ALTextView::redo(); -} - void ALCodeEditor::dropPlaceholdersLeft() { if (!mSnippet.active() || mSnippet.syncing()) @@ -4137,26 +4180,14 @@ void ALCodeEditor::dropTyping() bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) { - // Every place of a text being changed at once: what the key did, and - // the places brought up to it, one step with the typing around it -- - // but for a step back or forward, which ends it. - const ALEditorCommand command = keymap().lookup(key, mask); - const bool live = mSnippet.live() && command != ALEditorCommand::Undo && command != ALEditorCommand::Redo; - if (live) + hideCard(); + // At several carets the signature and a snippet's stops are not in + // play, each being about one place; the list is the main caret's. + if (hasOtherSelections() && typingText()) { - undoJournal().beginTyping(selection()); + hideSignature(); + clearPlaceholders(); } - const auto keep_up = atScopeEnd([this, live]() { - if (live) - { - if (mSnippet.live()) - { - syncMirrors(mSnippet.at(), false); - } - undoJournal().endTyping(); - } - }); - hideCard(); // The fixes listed take the keys that walk them and take one, in any // mode a modal keymap is in -- the list was asked for; any other key // lets them go and is the text's. @@ -4309,7 +4340,7 @@ bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) taking = at >= 0 && at < static_cast(line.size()) ? line[static_cast(at)] : 0; } const bool call_changes = hasSelection() || taking == '(' || taking == ',' || taking == ')'; - if (key == KEY_BACKSPACE && mask == MASK_NONE && mAutoClose && deletePair()) + if (key == KEY_BACKSPACE && mask == MASK_NONE && mAutoClose && (hasOtherSelections() ? deletePairs() : deletePair())) { if (mCards.signature() && mSignatureRequest && call_changes) { @@ -4335,16 +4366,21 @@ bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) { const bool typing = typingText(); const bool was_open = completionOpen(); + // At several carets the signature and a snippet's stops, each about + // one place, go; the list stays, the main caret's, and what is chosen + // from it goes in at each. + const bool several = typing && hasOtherSelections(); + if (several) + { + hideSignature(); + clearPlaceholders(); + } // The pair, the character and its outdent one key typed, one with the - // typing around it -- and every place of a text being changed at once - // brought up to it in the same step. + // typing around it. undoJournal().beginTyping(selection()); - const bool paired = typing && mAutoClose && uni_char < 0x80 && !isReadOnly() && typePair(static_cast(uni_char)); + const bool paired = typing && mAutoClose && uni_char < 0x80 && !isReadOnly() && + (several ? typePairs(static_cast(uni_char)) : typePair(static_cast(uni_char))); const bool typed = paired || ALTextView::handleUnicodeCharHere(uni_char); - if (typed && mSnippet.live()) - { - syncMirrors(mSnippet.at(), false); - } undoJournal().endTyping(); if (!typed) { @@ -4381,40 +4417,35 @@ bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) // A call begins, moves on to its next argument, or ends: asked again // then, and only then -- within an argument, the parameter shown // follows the caret here. - if (mSignatureRequest && (uni_char == '(' || uni_char == ',' || uni_char == ')')) + if (!several && mSignatureRequest && (uni_char == '(' || uni_char == ',' || uni_char == ')')) { mSignatureRequest(caret()); } return true; } -bool ALCodeEditor::typePair(char c) +std::optional ALCodeEditor::pairAt(const ALTextRange& selection, char c) { const std::shared_ptr grammar = highlighter().grammar(); if (!grammar || grammar->pairs().empty()) { - return false; + return std::nullopt; } const auto& pairs = grammar->pairs(); - const ALTextPos at = caret(); + const ALTextPos at = selection.end; const std::string& line = document().line(at.line); const char next = at.column < static_cast(line.size()) ? line[at.column] : '\0'; // Over the closer typing put in, rather than a second one. - if (!hasSelection() && next == c) + if (selection.empty() && next == c && std::find(mAutoClosed.begin(), mAutoClosed.end(), at) != mAutoClosed.end()) { - const auto put = std::find(mAutoClosed.begin(), mAutoClosed.end(), at); - if (put != mAutoClosed.end()) + for (const auto& [open, close] : pairs) { - for (const auto& [open, close] : pairs) + if (close == c) { - if (close == c) - { - // Where the typing goes on from, for the key after. - mAutoClosed.erase(put); - setCaret(ALTextPos(at.line, at.column + 1)); - undoJournal().settle(selection()); - return true; - } + Paired paired; + paired.over = true; + paired.change.caret = ALTextPos(at.line, at.column + 1); + return paired; } } } @@ -4424,23 +4455,24 @@ bool ALCodeEditor::typePair(char c) { continue; } - if (hasSelection()) + if (!selection.empty()) { // The selection wrapped in the pair, and still chosen inside it. - const ALTextRange sel = selection().normalised(); - const std::string inner = document().text(sel); - insertText(std::string(1, open) + inner + std::string(1, close)); - const ALTextPos end = caret(); - setSelection(ALTextRange(ALTextPos(sel.begin.line, sel.begin.column + 1), ALTextPos(end.line, end.column - 1))); - undoJournal().settle(selection()); - return true; + const ALTextRange sel = selection.normalised(); + const std::string inner = std::string(1, open) + document().text(sel); + Paired paired; + paired.change.replacements.push_back({ sel, inner + std::string(1, close) }); + paired.change.selects = true; + paired.change.anchor = ALTextPos(sel.begin.line, sel.begin.column + 1); + paired.change.caret = ALTextEditing::endOf(sel.begin, inner); + return paired; } // Not in a comment or a string, where it is prose; and only before // a blank, the line's end, or what closes or ends -- a bracket // opened before a word is about that word. if (inProse(at)) { - return false; + return std::nullopt; } bool room = next == '\0' || isspace(static_cast(next)) || strchr(";,", next) != nullptr; for (const auto& [o, closer] : pairs) @@ -4449,49 +4481,184 @@ bool ALCodeEditor::typePair(char c) } if (!room) { - return false; + return std::nullopt; } // A quote after a letter is an apostrophe, and after itself is the // end of an empty string. if (open == close && at.column > 0 && (alIdentifierByte(line[at.column - 1]) || line[at.column - 1] == open)) { - return false; + return std::nullopt; } - insertText(std::string(1, open) + std::string(1, close)); - const ALTextPos inside(at.line, at.column + 1); - setCaret(inside); + Paired paired; + paired.change.replacements.push_back({ ALTextRange(at, at), std::string(1, open) + std::string(1, close) }); + paired.change.caret = ALTextPos(at.line, at.column + 1); + paired.closes = true; + return paired; + } + return std::nullopt; +} + +bool ALCodeEditor::typePair(char c) +{ + const std::optional paired = pairAt(selection(), c); + if (!paired) + { + return false; + } + if (paired->over) + { + // Where the typing goes on from, for the key after. + mAutoClosed.eraseIf([at = caret()](const ALTextPos& closer) { return closer == at; }); + setCaret(paired->change.caret); undoJournal().settle(selection()); - mAutoClosed.insert(inside); return true; } - return false; + insertText(paired->change.replacements.front().text); + if (paired->change.selects) + { + setSelection(ALTextRange(paired->change.anchor, paired->change.caret)); + } + else + { + setCaret(paired->change.caret); + } + undoJournal().settle(selection()); + if (paired->closes) + { + mAutoClosed.insert(caret()); + } + return true; } -bool ALCodeEditor::deletePair() +bool ALCodeEditor::typePairs(char c) { - const std::shared_ptr grammar = highlighter().grammar(); - const ALTextPos at = caret(); - const auto put = std::find(mAutoClosed.begin(), mAutoClosed.end(), at); - if (!grammar || hasSelection() || put == mAutoClosed.end() || at.column == 0) + // Worked out at each before any is made. + const std::vector all = selectionsInOrder(); + std::vector> paired; + paired.reserve(all.size()); + bool any = false; + for (const ALTextRange& one : all) + { + paired.push_back(pairAt(one, c)); + any = any || paired.back().has_value(); + } + if (!any) { return false; } + // The closers typed over are let go of before the edit moves them. + for (size_t i = 0; i < all.size(); ++i) + { + if (paired[i] && paired[i]->over) + { + mAutoClosed.eraseIf([at = all[i].end](const ALTextPos& closer) { return closer == at; }); + } + } + const std::string plain(1, c); + editEach([&](size_t i, const ALTextRange& selection) { + if (paired[i]) + { + return std::optional(paired[i]->change); + } + const ALTextRange over = selection.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, plain }); + one.caret = ALTextEditing::endOf(over.begin, plain); + return std::optional(std::move(one)); + }); + // Each closer put in, before the caret that was put inside its pair. + const std::vector now = selectionsInOrder(); + if (now.size() == all.size()) + { + for (size_t i = 0; i < now.size(); ++i) + { + if (paired[i] && paired[i]->closes) + { + mAutoClosed.insert(now[i].end); + } + } + // A plain one typed where none paired, brought out as it would be + // were it typed alone. + outdentEach(static_cast(static_cast(c)), [&paired](size_t i) { return !paired[i]; }); + } + undoJournal().settle(selection()); + return true; +} + +std::optional ALCodeEditor::pairAround(const ALTextPos& at) +{ + const std::shared_ptr grammar = highlighter().grammar(); + if (!grammar || at.column == 0 || std::find(mAutoClosed.begin(), mAutoClosed.end(), at) == mAutoClosed.end()) + { + return std::nullopt; + } const std::string& line = document().line(at.line); if (at.column >= static_cast(line.size())) { - return false; + return std::nullopt; } for (const auto& [open, close] : grammar->pairs()) { if (line[at.column - 1] == open && line[at.column] == close) { - mAutoClosed.erase(put); - setSelection(ALTextRange(ALTextPos(at.line, at.column - 1), ALTextPos(at.line, at.column + 1))); - insertText(std::string()); - return true; + return ALTextRange(ALTextPos(at.line, at.column - 1), ALTextPos(at.line, at.column + 1)); } } - return false; + return std::nullopt; +} + +bool ALCodeEditor::deletePair() +{ + if (hasSelection()) + { + return false; + } + const std::optional pair = pairAround(caret()); + if (!pair) + { + return false; + } + mAutoClosed.eraseIf([at = caret()](const ALTextPos& closer) { return closer == at; }); + setSelection(*pair); + insertText(std::string()); + return true; +} + +bool ALCodeEditor::deletePairs() +{ + const std::vector all = selectionsInOrder(); + std::vector> pairs; + pairs.reserve(all.size()); + bool any = false; + for (const ALTextRange& one : all) + { + pairs.push_back(one.empty() ? pairAround(one.end) : std::nullopt); + any = any || pairs.back().has_value(); + if (pairs.back()) + { + mAutoClosed.eraseIf([at = one.end](const ALTextPos& closer) { return closer == at; }); + } + } + if (!any) + { + return false; + } + // One Backspace at each: the pair where there is one, else what a + // Backspace takes there. + undoJournal().beginTyping(selection(), true); + editEach([&](size_t i, const ALTextRange& selection) -> std::optional { + const std::optional range = pairs[i] ? pairs[i] : erasedBy(ALEditorCommand::DeleteLeft, selection); + if (!range || range->empty()) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back({ range->normalised(), std::string() }); + one.caret = range->normalised().begin; + return one; + }); + undoJournal().endTyping(); + return true; } bool ALCodeEditor::handleMouseDown(S32 x, S32 y, MASK mask) @@ -4558,6 +4725,7 @@ bool ALCodeEditor::handleMouseDown(S32 x, S32 y, MASK mask) const ALTextPos from(line, 0); const ALTextPos to = line < last ? ALTextPos(line + 1, 0) : ALTextPos(line, static_cast(document().line(line).size())); setFocus(true); + singleSelection(); if ((mask & MASK_SHIFT) && hasSelection()) { const ALTextRange was = selection(); @@ -4583,6 +4751,7 @@ bool ALCodeEditor::handleMouseDown(S32 x, S32 y, MASK mask) if (mask == MASK_CONTROL && (mSymbolRequest || mLinkRequest) && text.pointInRect(x, y)) { const ALTextPos at = posAtLocal(x, y, false); + singleSelection(); if (mLinkRequest && !mLinkRequest(at, false).empty()) { setFocus(true); @@ -4818,8 +4987,15 @@ void ALCodeEditor::draw() { hideSignature(); } - // A closer put in is typed over only on its own line. - mAutoClosed.eraseIf([this](const ALTextPos& at) { return at.line != caret().line; }); + // A closer put in is typed over only on its own line: a caret's. + mAutoClosed.eraseIf([this](const ALTextPos& at) { + if (at.line == caret().line) + { + return false; + } + const std::vector& others = otherSelections(); + return std::none_of(others.begin(), others.end(), [&at](const ALTextRange& other) { return other.end.line == at.line; }); + }); // The name under the caret lit once the caret has rested, and again // where the view has scrolled past the lines it was lit over. if (mLightsOccurrences && diff --git a/indra/llui/alcodeeditor.h b/indra/llui/alcodeeditor.h index de33571d5f..979627c414 100644 --- a/indra/llui/alcodeeditor.h +++ b/indra/llui/alcodeeditor.h @@ -473,7 +473,11 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures const std::vector& completions() const { return mCompletionModel.list(); } S32 chosenCompletion() const; // What accepting does, for whoever has a completion in hand without - // the list: the completion in place of the range. + // the list: the completion in place of the range. Where there are + // several carets it goes in at each -- in place of what reads as the + // range does on either side of it, else at it -- each with its own + // indentation, and each caret on its first parameter or stop, with + // none to Tab through. void complete(const Completion& chosen, const ALTextRange& range); // --- snippets -------------------------------------------------------------------- @@ -540,17 +544,14 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures // signature and the stops. void dropTyping(); - // Every place of a text changed at once, until there are several - // carets: the selection a stop, and the places that read as it does - // its mirrors, brought up as each key is typed and lit while they - // are; Escape, Tab, or the caret off the stop's line lets them go. + // Every place of a text changed at once, each a selection of its own. // Select Next Occurrence takes the name at the caret, then adds the - // next place after the last one taken, going round; Change All takes - // every place at once. A name taken so matches whole names only. + // next place after the main selection, going round, which becomes the + // main one; Change All selects every place at once, the main one kept + // main and the others it had let go. A name taken so matches whole + // names only. bool selectNextOccurrence(); bool changeAllOccurrences(); - void undo() override; - void redo() override; // The names of a signature's parameters, and where their list opens // (ALSnippetSession). static std::vector parameterNames(std::string_view detail, std::string_view name = std::string_view()) @@ -838,11 +839,32 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures bool hoverCardAt(S32 x, S32 y); // A pair typed as it was typed: the opener closed, the closer typed // over, a selection wrapped. False where the character is for the - // text as ever. + // text as ever. At every selection where there are several, each as + // it would be on its own, a character as ever at those it pairs + // nothing at; false where it pairs nothing at any. bool typePair(char c); + bool typePairs(char c); + // A completion in place of a stretch, as a change: the snippet, the + // call or the name, and where the selection is after. + ALTextEditing::Change completionAt(const Completion& chosen, const ALTextRange& over); + // What a character typed at a selection does as a pair: what it + // replaces and where the selection goes, as a change; whether that + // goes over a closer typing put in, or puts one in at the caret after. + // Nothing where the character is for the text as ever. + struct Paired + { + ALTextEditing::Change change; + bool over = false; + bool closes = false; + }; + std::optional pairAt(const ALTextRange& selection, char c); // The pair around the caret that one Backspace takes away, if the - // closer is one typing put in. + // closer is one typing put in: at the caret, or as one Backspace at + // every caret where there are several, the pairs at those it finds + // one at. False where it finds none. bool deletePair(); + bool deletePairs(); + std::optional pairAround(const ALTextPos& at); // The problems squiggled under a position, and the stretch they span. std::vector problemsUnder(const ALTextPos& at, ALTextRange& about) const; void vocabularyCompletions(std::string_view prefix, std::vector& out); @@ -1052,15 +1074,19 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures bool mCompletionMoved = false; // The stops of a snippet or a call being filled in (ALSnippetSession). ALSnippetSession mSnippet; - // Each placeholder's mirrors made what it holds: as one step of its - // own, or as part of the key being typed. - void syncMirrors(S32 index, bool grouped = true); + // Each placeholder's mirrors made what it holds, as one step of its + // own. + void syncMirrors(S32 index); + // The selections folding has hidden, the main one among them, each a + // caret at the end of the fold's line. + void caretsOutOfFolds(); // The places after `from` that read as `wanted`, going round, whole - // names only where asked, none of the stop's or its mirrors'; at most - // `most` of them. - std::vector placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most) const; + // names only where asked, none over any of `taken` -- the selections + // there are; at most `most` of them. + std::vector placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most, + const std::vector& taken) const; // The name Select Next Occurrence took at the caret, whose places are - // whole names only. + // whole names only: the main selection while it is one of them. ALTextRange mOccurrenceName; // The placeholders let go of where the caret has left the lines they // and where the call or the snippet ends are on. diff --git a/indra/llui/alfindbar.cpp b/indra/llui/alfindbar.cpp index 9099d5dcd0..c007315984 100644 --- a/indra/llui/alfindbar.cpp +++ b/indra/llui/alfindbar.cpp @@ -490,13 +490,18 @@ bool ALFindBar::handleKeyHere(KEY key, MASK mask) } } // Return goes on through the fields and buttons by themselves; with - // Shift, back; with REPLACE_ALL_MASK, every one replaced where the - // second row is out. + // Shift, back; with Alt, every match selected; with REPLACE_ALL_MASK, + // every one replaced where the second row is out. if (key == KEY_RETURN && mask == MASK_SHIFT) { mPrevious(); return true; } + if (key == KEY_RETURN && mask == MASK_ALT) + { + mSelectAll(); + return true; + } if (key == KEY_RETURN && mask == REPLACE_ALL_MASK) { // Only with the replacement in sight: what it is was seen. diff --git a/indra/llui/alfindbar.h b/indra/llui/alfindbar.h index 75ae9bf8a1..e54858dd2a 100644 --- a/indra/llui/alfindbar.h +++ b/indra/llui/alfindbar.h @@ -48,9 +48,10 @@ class LLTextBox; // in turn, and Space or Return presses the one it is on; and while the // bar has the keyboard, Alt with C, W, R or L -- Command and Option with // them on a Mac -- turns case, whole words, patterns or the selection on -// or off, as the modern editors have it; Shift and Return goes back, and -// Control, Alt and Return -- Command, Option and Return -- replaces every -// one. +// or off, as the modern editors have it; Shift and Return goes back, +// Alt and Return -- Option and Return -- selects every match, each a +// selection of its own, and Control, Alt and Return -- Command, Option and +// Return -- replaces every one. class ALFindBar : public LLPanel { public: @@ -100,6 +101,7 @@ class ALFindBar : public LLPanel boost::signals2::connection onPrevious(const signal_t::slot_type& cb) { return mPrevious.connect(cb); } boost::signals2::connection onReplace(const signal_t::slot_type& cb) { return mReplace.connect(cb); } boost::signals2::connection onReplaceAll(const signal_t::slot_type& cb) { return mReplaceAll.connect(cb); } + boost::signals2::connection onSelectAll(const signal_t::slot_type& cb) { return mSelectAll.connect(cb); } boost::signals2::connection onClose(const signal_t::slot_type& cb) { return mClose.connect(cb); } void draw() override; @@ -139,5 +141,5 @@ class ALFindBar : public LLPanel Flat* mReplaceOne = nullptr; Flat* mReplaceEvery = nullptr; - signal_t mChanged, mNext, mPrevious, mReplace, mReplaceAll, mClose; + signal_t mChanged, mNext, mPrevious, mReplace, mReplaceAll, mSelectAll, mClose; }; diff --git a/indra/llui/alfindings.h b/indra/llui/alfindings.h index eac05d859c..9fc4a7aea5 100644 --- a/indra/llui/alfindings.h +++ b/indra/llui/alfindings.h @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -75,6 +76,10 @@ class ALFindings public: typedef Finding finding_t; typedef ALFindingLevel Level; + // A file's findings as they are held: shared, so that whoever made + // them and keeps them too -- a script's tab -- hands them over without + // a copy, neither changing them after. + typedef std::shared_ptr> shared_t; // Everything one file said, in place of whatever it said before. A file // that said nothing is still a file that was checked, and is kept as one @@ -83,6 +88,10 @@ class ALFindings // rebuild, and its rows should not jump to the end of a list of every // file's each time. void replace(const std::string& file, std::vector found) + { + replace(file, std::make_shared>(std::move(found))); + } + void replace(const std::string& file, shared_t found) { if (file.empty()) { @@ -92,9 +101,13 @@ class ALFindings { mFiles.push_back(file); } + if (!found) + { + found = std::make_shared>(); + } Counts counts; - for (const Finding& finding : found) + for (const Finding& finding : *found) { counts.take(finding, 1); mTotal.take(finding, 1); @@ -163,9 +176,10 @@ class ALFindings { continue; } - for (size_t at = 0; at < held->second.size(); ++at) + const std::vector& found = *held->second; + for (size_t at = 0; at < found.size(); ++at) { - const Finding& finding = held->second[at]; + const Finding& finding = found[at]; switch (Traits::level(finding)) { case Level::Error: if (!query.errors) { continue; } break; @@ -202,7 +216,7 @@ class ALFindings const Finding* at(std::string_view file, size_t index) const { const auto held = mByFile.find(file); - return held != mByFile.end() && index < held->second.size() ? &held->second[index] : nullptr; + return held != mByFile.end() && index < held->second->size() ? &(*held->second)[index] : nullptr; } // The files that have been checked, in the order they first were. @@ -242,7 +256,7 @@ class ALFindings boost::unordered_flat_map> tally; for (const auto& [file, found] : mByFile) { - for (const Finding& finding : found) + for (const Finding& finding : *found) { ++tally[Traits::rule(finding)]; } @@ -265,7 +279,7 @@ class ALFindings { return false; } - for (const Finding& finding : held->second) + for (const Finding& finding : *held->second) { mTotal.take(finding, -1); } @@ -300,7 +314,7 @@ class ALFindings } }; - boost::unordered_flat_map, ll::string_hash, std::equal_to<>> mByFile; + boost::unordered_flat_map> mByFile; boost::unordered_flat_map> mCountByFile; std::vector mFiles; Counts mTotal; diff --git a/indra/llui/alkeymap.cpp b/indra/llui/alkeymap.cpp index e853e4921c..d3ede52aea 100644 --- a/indra/llui/alkeymap.cpp +++ b/indra/llui/alkeymap.cpp @@ -47,7 +47,8 @@ const char* alEditorCommandName(ALEditorCommand command) "join_lines", "previous_change", "next_change", "next_function", "previous_function", "select_function", "go_to_bracket", "insert_line_below", "insert_line_above", "select_line", "move_subword_left", "move_subword_right", "select_subword_left", "select_subword_right", "expand_selection", "shrink_selection", - "select_next_occurrence", "change_all_occurrences", + "select_next_occurrence", "change_all_occurrences", "add_caret_above", "add_caret_below", + "column_select_left", "column_select_right", "column_select_up", "column_select_down", }; static_assert(sizeof(NAMES) / sizeof(NAMES[0]) == static_cast(ALEditorCommand::COUNT), "every command has a name"); const size_t index = static_cast(command); @@ -268,8 +269,13 @@ ALKeymap ALKeymap::standard() #endif map.bind(KEY_LEFT, part, C::MoveSubwordLeft); map.bind(KEY_RIGHT, part, C::MoveSubwordRight); +#if LL_DARWIN map.bind(KEY_LEFT, part | MASK_SHIFT, C::SelectSubwordLeft); map.bind(KEY_RIGHT, part | MASK_SHIFT, C::SelectSubwordRight); +#endif + // Elsewhere Control-Shift-Alt with an arrow grows a column (below), as + // Visual Studio Code has it; selecting by parts there takes a key + // given it by hand. #if LL_DARWIN // Control-Shift-Command with an arrow there, Shift-Option being words. const MASK grow = MASK_MAC_CONTROL | MASK_SHIFT | MASK_CONTROL; @@ -282,6 +288,15 @@ ALKeymap ALKeymap::standard() map.bind('D', MASK_CONTROL, C::SelectNextOccurrence); map.bind('L', MASK_CONTROL | MASK_SHIFT, C::ChangeAllOccurrences); map.bind(KEY_F2, MASK_CONTROL, C::ChangeAllOccurrences); + // As Visual Studio Code has them: Command and Option on a Mac. + map.bind(KEY_UP, MASK_CONTROL | MASK_ALT, C::AddCaretAbove); + map.bind(KEY_DOWN, MASK_CONTROL | MASK_ALT, C::AddCaretBelow); + // As Visual Studio Code has them: Command-Shift-Option on a Mac. + const MASK column = MASK_CONTROL | MASK_SHIFT | MASK_ALT; + map.bind(KEY_LEFT, column, C::ColumnSelectLeft); + map.bind(KEY_RIGHT, column, C::ColumnSelectRight); + map.bind(KEY_UP, column, C::ColumnSelectUp); + map.bind(KEY_DOWN, column, C::ColumnSelectDown); #if LL_DARWIN // The Mac's own, where MASK_CONTROL is Command: Command with an arrow // goes to the ends of the line and of the text, Option with one by diff --git a/indra/llui/alkeymap.h b/indra/llui/alkeymap.h index c0a68d9407..8373d1f683 100644 --- a/indra/llui/alkeymap.h +++ b/indra/llui/alkeymap.h @@ -123,9 +123,21 @@ enum class ALEditorCommand : U8 // The selection grown to what holds it as code reads it, and back. ExpandSelection, ShrinkSelection, - // Every place of a text changed at once: the next place taken, or all. + // Every place of a text changed at once: the next place taken, or all, + // each a selection of its own. SelectNextOccurrence, ChangeAllOccurrences, + // A caret more on the row above each caret, or below it: a column of + // them grown by a row. + AddCaretAbove, + AddCaretBelow, + // A column selection grown at its moving corner by a column either + // way or a row up or down: begun from the selection there is where + // there is no column. + ColumnSelectLeft, + ColumnSelectRight, + ColumnSelectUp, + ColumnSelectDown, COUNT }; @@ -160,7 +172,8 @@ class ALKeymap // own: control-slash comments, alt-up and alt-down move lines, // control-shift-D duplicates one and control-shift-K deletes one, // control-shift with a square bracket folds and unfolds, - // control-space completes, F12 goes to a definition, shift-F12 finds + // control-space completes, control-alt with up or down adds a caret + // above or below, F12 goes to a definition, shift-F12 finds // the references, F2 renames, control-F finds, control-H (and on a // Mac, which keeps command-H, command-option-F) replaces, and F3 and // shift-F3 go to the next and the previous match. Backspace and Return diff --git a/indra/llui/alsnippetsession.cpp b/indra/llui/alsnippetsession.cpp index 647f6612d6..5424975805 100644 --- a/indra/llui/alsnippetsession.cpp +++ b/indra/llui/alsnippetsession.cpp @@ -327,12 +327,10 @@ void ALSnippetSession::start(std::vector stops, const ALTextPos& af mMirrors = std::move(mirrors); mLanding = landing; mSyncing = -1; - mLive = false; } void ALSnippetSession::clear() { - mLive = false; mMirrors.clear(); mLanding = 0; mStops.clear(); diff --git a/indra/llui/alsnippetsession.h b/indra/llui/alsnippetsession.h index e64abef61d..a02af29d91 100644 --- a/indra/llui/alsnippetsession.h +++ b/indra/llui/alsnippetsession.h @@ -90,12 +90,6 @@ class ALSnippetSession const ALTextPos& after() const { return mAfter; } S32 landing() const { return mLanding; } const std::vector& mirrors() const { return mMirrors; } - // Another place that repeats a stop, added as it is found. - void addMirror(Mirror mirror) { mMirrors.push_back(std::move(mirror)); } - // Whether the mirrors are brought up as each key is typed, rather than - // as the stop is left: every place of a name being changed at once. - void setLive(bool live) { mLive = live; } - bool live() const { return mLive && active(); } // An edit heard: the stop being typed over becomes what was typed, the // others move with the text, and one the edit cut into goes, with its @@ -124,7 +118,6 @@ class ALSnippetSession ALTextPos mAfter; std::vector mMirrors; S32 mSyncing = -1; - bool mLive = false; // How long the text `${0:text}` put where the caret lands is, to be // chosen as it lands; nothing for a bare $0. S32 mLanding = 0; diff --git a/indra/llui/altextcarets.cpp b/indra/llui/altextcarets.cpp new file mode 100644 index 0000000000..5d3645f40b --- /dev/null +++ b/indra/llui/altextcarets.cpp @@ -0,0 +1,164 @@ +/** + * @file altextcarets.cpp + * @brief The selections a text view has besides its main one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "altextcarets.h" + +void ALTextCarets::assign(std::vector selections) +{ + mSelections.assign(std::move(selections)); + sweep(nullptr); +} + +void ALTextCarets::add(const ALTextRange& selection) +{ + mSelections.insert(selection); + sweep(nullptr); +} + +bool ALTextCarets::merge(ALTextRange& main) +{ + return sweep(&main); +} + +void ALTextCarets::apply(const ALTextDocument::Edit& edit) +{ + if (mSelections.empty()) + { + return; + } + bool moved = false; + mSelections.apply( + edit, + [&moved](ALTextRange& selection, const ALTextDocument::Edit& e) { + const ALTextRange after = slid(selection, e); + moved = moved || after != selection; + selection = after; + return true; + }, + [](ALTextRange&) {}); + // Only what moved can have come to meet another. + if (moved) + { + sweep(nullptr); + } +} + +// static +ALTextRange ALTextCarets::slid(const ALTextRange& selection, const ALTextDocument::Edit& edit) +{ + if (selection.empty()) + { + const ALTextPos at = edit.placed(selection.end); + return ALTextRange(at, at); + } + const ALTextRange ordered = selection.normalised(); + const ALTextPos begin = edit.placed(ordered.begin); + const ALTextPos end = std::max(begin, edit.placed(ordered.end, false)); + return selection.end < selection.begin ? ALTextRange(end, begin) : ALTextRange(begin, end); +} + +// static +bool ALTextCarets::meet(const ALTextRange& first, const ALTextRange& second) +{ + const ALTextRange a = first.normalised(); + const ALTextRange b = second.normalised(); + return (a.empty() || b.empty()) ? b.begin <= a.end : b.begin < a.end; +} + +// static +ALTextRange ALTextCarets::joined(const ALTextRange& winner, const ALTextRange& loser) +{ + const ALTextRange w = winner.normalised(); + const ALTextRange l = loser.normalised(); + const ALTextPos begin = std::min(w.begin, l.begin); + const ALTextPos end = std::max(w.end, l.end); + const ALTextRange& way = winner.empty() ? loser : winner; + return way.end < way.begin ? ALTextRange(end, begin) : ALTextRange(begin, end); +} + +bool ALTextCarets::sweep(ALTextRange* main) +{ + if (mSelections.empty()) + { + return false; + } + // In the order they begin, the main selection before any that begins + // where it does; each joined to the one kept before it where they meet, + // the main one winning, and the one kept first otherwise. + std::vector items = mSelections.take(); + const ALTextRange was = main ? *main : ALTextRange(); + std::vector kept; + kept.reserve(items.size()); + bool main_kept = false; + // Which of those kept is the main selection, once it is. + size_t main_at = 0; + const auto take = [&](const ALTextRange& next, bool is_main) { + if (!kept.empty() && meet(kept.back(), next)) + { + kept.back() = is_main ? joined(next, kept.back()) : joined(kept.back(), next); + } + else + { + kept.push_back(next); + } + if (is_main) + { + main_kept = true; + main_at = kept.size() - 1; + } + }; + const ALTextPos main_begin = was.normalised().begin; + bool main_due = main != nullptr; + for (const ALTextRange& item : items) + { + if (main_due && main_begin <= item.normalised().begin) + { + take(was, true); + main_due = false; + } + take(item, false); + } + if (main_due) + { + take(was, true); + } + // Back as they were: the main one to the view, the rest here, still in + // the order they begin. + for (size_t i = 0; i < kept.size(); ++i) + { + if (main_kept && i == main_at) + { + continue; + } + mSelections.push_back(kept[i]); + } + if (!main) + { + return false; + } + *main = kept[main_at]; + return *main != was; +} diff --git a/indra/llui/altextcarets.h b/indra/llui/altextcarets.h new file mode 100644 index 0000000000..5a4ec29011 --- /dev/null +++ b/indra/llui/altextcarets.h @@ -0,0 +1,105 @@ +/** + * @file altextcarets.h + * @brief The selections a text view has besides its main one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#ifndef AL_ALTEXTCARETS_H +#define AL_ALTEXTCARETS_H + +#include "alanchoredranges.h" + +#include +#include + +// The selections a view has besides its main one, which stays the view's +// own so that everything that knows of one selection goes on working. Each +// is a range from its anchor to its caret, as the view's selection is -- +// the caret before the anchor where it was made backwards -- and an empty +// one is a caret alone. They are kept in the order they begin and in step +// with the text's edits, as ALAnchoredRanges keeps what is laid over it: +// a caret is pushed along by text put in right at it, as typing pushes the +// main one, and a selection keeps what it held, what is put in at either +// edge staying outside it. +// +// None is over another. Two selections that overlap become one, and so do +// a caret and a selection or a caret it touches; two selections that only +// touch -- two matches side by side -- stay two. One that meets the main +// selection becomes part of it, the main one keeping its place as main. +class ALTextCarets +{ +public: + // Where one is in the text, in order: its range, normalised. + struct Normalised + { + ALTextRange operator()(const ALTextRange& selection) const { return selection.normalised(); } + }; + typedef ALAnchoredRanges ranges_t; + typedef ranges_t::const_iterator const_iterator; + + const std::vector& selections() const { return mSelections.items(); } + size_t size() const { return mSelections.size(); } + bool empty() const { return mSelections.empty(); } + const ALTextRange& operator[](size_t i) const { return mSelections[i]; } + const_iterator begin() const { return mSelections.begin(); } + const_iterator end() const { return mSelections.end(); } + // Those that may lie on a line, in order; each still asked whether it + // does, one that began above it having perhaps ended. + std::pair onLine(S32 line) const { return mSelections.onLine(line); } + + // All of them at once, or one more, merged where they meet each other. + // The main selection is merged with them by merge(), which whoever + // holds it asks once it has put it. + void assign(std::vector selections); + void add(const ALTextRange& selection); + void clear() { mSelections.clear(); } + + // Those that meet merged, the main selection with them, which grows by + // any it meets: what the main one put somewhere asks. Whether it changed. + bool merge(ALTextRange& main); + + // An edit made: each slid along with the text, and those it brought + // together merged -- among themselves, since the main selection is + // placed by whoever made the edit. + void apply(const ALTextDocument::Edit& edit); + + // A selection moved along by an edit as these are: a caret pushed past + // what was put in at it, a selection's ends kept outside what was put + // in at them, and either way back to where the edit began where the + // edit took what it stood on. + static ALTextRange slid(const ALTextRange& selection, const ALTextDocument::Edit& edit); + // Whether two selections, the first beginning at or before the second, + // are one: overlapping, or touching where either is a caret. + static bool meet(const ALTextRange& first, const ALTextRange& second); + // Two that meet as one, over both, running the way `winner` runs -- its + // caret at the end or at the start -- or the way `loser` does where the + // winner is a caret. + static ALTextRange joined(const ALTextRange& winner, const ALTextRange& loser); + +private: + // Each that meets the one before it joined to it, the main selection + // among them where one is given. + bool sweep(ALTextRange* main); + + ranges_t mSelections; +}; + +#endif // AL_ALTEXTCARETS_H diff --git a/indra/llui/altextdocument.cpp b/indra/llui/altextdocument.cpp index 880a85b535..a15d6b0ef6 100644 --- a/indra/llui/altextdocument.cpp +++ b/indra/llui/altextdocument.cpp @@ -189,12 +189,14 @@ const std::vector& ALTextDocument::Edit::lineSpa return out; } -ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos) const +ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos, bool pushed) const { if (parts.empty()) { const ALTextRange removed = range.normalised(); - if (pos < removed.begin) + // Text put in right at it, and nothing taken, leaves it where it + // was unless it is pushed. + if (pos < removed.begin || (!pushed && pos == removed.begin)) { return pos; } @@ -205,7 +207,18 @@ ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos) const { return in->after.begin; } - return mapped(pos, true); + return mapped(pos, pushed); +} + +std::optional ALTextDocument::Edit::replacedAround(const ALTextPos& pos) const +{ + if (parts.empty()) + { + const ALTextRange removed = range.normalised(); + return removed.begin <= pos && pos < removed.end ? std::optional(removed) : std::nullopt; + } + const auto in = std::upper_bound(parts.begin(), parts.end(), pos, [](const ALTextPos& p, const Part& part) { return p < part.before.end; }); + return in != parts.end() && in->before.begin <= pos ? std::optional(in->before) : std::nullopt; } S32 ALTextDocument::Edit::lineAfter(S32 line) const diff --git a/indra/llui/altextdocument.h b/indra/llui/altextdocument.h index be74ed32b5..0279d0df34 100644 --- a/indra/llui/altextdocument.h +++ b/indra/llui/altextdocument.h @@ -29,6 +29,7 @@ #include +#include #include #include #include @@ -176,8 +177,15 @@ class ALTextDocument // A place kept beside the text -- a mark -- as a keeper of marks has // it: before what was replaced it stays, inside it lands where that // began, after it moves along. For a batch, by the stretch it is in - // or the ones before it. - ALTextPos placed(const ALTextPos& pos) const; + // or the ones before it. Text put in right at it pushes it along, + // as typing pushes a caret, unless not `pushed`: the end of a + // selection, which what is put in right after it is not part of. + ALTextPos placed(const ALTextPos& pos, bool pushed = true) const; + // The stretch, as it was, whose text the character at a position + // stood in -- what was replaced from at or before it to past it -- + // or nothing. For a batch, its own stretch only, not the text + // between them. + std::optional replacedAround(const ALTextPos& pos) const; // Where a line is after, or -1 for one the edit replaced: one of a // run of lines, a line typed in included, is not the line it was. S32 lineAfter(S32 line) const; diff --git a/indra/llui/altextediting.cpp b/indra/llui/altextediting.cpp index 06d7007d0a..1fde4d6b4b 100644 --- a/indra/llui/altextediting.cpp +++ b/indra/llui/altextediting.cpp @@ -27,6 +27,8 @@ #include "altextediting.h" +#include + namespace { bool isBlank(char c) @@ -44,6 +46,104 @@ namespace } return n; } + + // Lines first through last again, under them. + ALTextEditing::Replacement duplicated(const ALTextDocument& doc, S32 first, S32 last) + { + const std::string block = doc.text(ALTextRange(doc.lineStart(first), doc.lineEnd(last))); + return { ALTextRange(doc.lineEnd(last), doc.lineEnd(last)), "\n" + block }; + } + + // What deleting lines first through last takes -- the last line of the + // text with the break before it -- and the line a caret lands on once + // they are gone, and how long that is. + ALTextRange deleted(const ALTextDocument& doc, S32 first, S32 last, S32& line, S32& length) + { + const S32 count = doc.lineCount(); + ALTextRange range(doc.lineStart(first), last + 1 < count ? doc.lineStart(last + 1) : doc.lineEnd(last)); + line = first; + length = last + 1 < count ? doc.lineLength(last + 1) : 0; + if (last + 1 >= count && first > 0) + { + range.begin = doc.lineEnd(first - 1); + line = first - 1; + length = doc.lineLength(first - 1); + } + return range; + } + + // Whether every line first through last that says anything is + // commented with the token; nothing where none says anything. + std::optional commented(const ALTextDocument& doc, S32 first, S32 last, const std::string& token) + { + bool all_commented = true; + bool any_text = false; + for (S32 l = first; l <= last; ++l) + { + const std::string& line = doc.line(l); + const S32 n = indentationOf(line); + if (n == static_cast(line.size())) + { + continue; + } + any_text = true; + if (line.compare(n, token.size(), token) != 0) + { + all_commented = false; + } + } + if (!any_text) + { + return std::nullopt; + } + return all_commented; + } + + // Each line first through last that says anything commented out, the + // token and the space after it taken from where its text begins; or in, + // the token and a space put there. + std::vector commentLines(const ALTextDocument& doc, S32 first, S32 last, const std::string& token, bool out) + { + std::vector replacements; + for (S32 l = first; l <= last; ++l) + { + const std::string& line = doc.line(l); + const S32 n = indentationOf(line); + if (n == static_cast(line.size())) + { + continue; + } + if (out) + { + S32 taken = static_cast(token.size()); + if (n + taken < static_cast(line.size()) && line[n + taken] == ' ') + { + ++taken; + } + replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n + taken)), std::string() }); + } + else + { + replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n)), token + " " }); + } + } + return replacements; + } + + // A selection's lines whole, from the first's start to the next line's + // start, or to the end of the text's last line as the replacements + // leave it. + ALTextRange wholeLines(const ALTextDocument& doc, const ALTextRange& selection, const std::vector& replacements) + { + const auto [first, last] = ALTextEditing::selectedLines(selection); + return ALTextRange(ALTextPos(first, 0), last + 1 < doc.lineCount() ? ALTextPos(last + 1, 0) + : ALTextEditing::placedThrough(replacements, doc.lineEnd(last))); + } + + ALTextRange lineShifted(const ALTextRange& selection, S32 lines) + { + return ALTextRange(ALTextPos(selection.begin.line + lines, selection.begin.column), ALTextPos(selection.end.line + lines, selection.end.column)); + } } namespace ALTextEditing @@ -62,10 +162,9 @@ std::pair selectedLines(const ALTextRange& selection) Change duplicateLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret) { const auto [first, last] = selectedLines(ALTextRange(anchor, caret)); - const std::string block = doc.text(ALTextRange(doc.lineStart(first), doc.lineEnd(last))); const S32 count = last - first + 1; Change change; - change.replacements.push_back({ ALTextRange(doc.lineEnd(last), doc.lineEnd(last)), "\n" + block }); + change.replacements.push_back(duplicated(doc, first, last)); // The caret and the selection go with the copy. change.selects = true; change.anchor = ALTextPos(anchor.line + count, anchor.column); @@ -145,19 +244,11 @@ Change copyLinesTo(const ALTextDocument& doc, S32 first, S32 last, S32 below) Change deleteLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret) { const auto [first, last] = selectedLines(ALTextRange(anchor, caret)); - const S32 count = doc.lineCount(); - ALTextRange range(doc.lineStart(first), last + 1 < count ? doc.lineStart(last + 1) : doc.lineEnd(last)); // The line the caret lands on, and how long it is, once they are gone. - S32 line = first; - S32 length = last + 1 < count ? doc.lineLength(last + 1) : 0; - if (last + 1 >= count && first > 0) - { - // The last line goes with the newline before it. - range.begin = doc.lineEnd(first - 1); - line = first - 1; - length = doc.lineLength(first - 1); - } - Change change; + S32 line = first; + S32 length = 0; + const ALTextRange range = deleted(doc, first, last, line, length); + Change change; change.replacements.push_back({ range, std::string() }); change.caret = ALTextPos(line, llmin(caret.column, length)); change.anchor = change.caret; @@ -167,79 +258,28 @@ Change deleteLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALT std::optional toggleComment(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, const std::string& token) { const auto [first, last] = selectedLines(ALTextRange(anchor, caret)); - // Out where every line that says anything is commented; in otherwise. - bool all_commented = true; - bool any_text = false; - for (S32 l = first; l <= last; ++l) - { - const std::string& line = doc.line(l); - const S32 n = indentationOf(line); - if (n == static_cast(line.size())) - { - continue; - } - any_text = true; - if (line.compare(n, token.size(), token) != 0) - { - all_commented = false; - } - } - if (!any_text) + const std::optional out = commented(doc, first, last, token); + if (!out) { return std::nullopt; } Change change; - S32 caret_shift = 0; - // What the last line gained or lost, for where a selection of the - // lines whole ends. - S32 last_delta = 0; - for (S32 l = first; l <= last; ++l) - { - const std::string& line = doc.line(l); - const S32 n = indentationOf(line); - if (n == static_cast(line.size())) - { - continue; - } - S32 delta = 0; - if (all_commented) - { - S32 taken = static_cast(token.size()); - if (n + taken < static_cast(line.size()) && line[n + taken] == ' ') - { - ++taken; - } - change.replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n + taken)), std::string() }); - delta = -taken; - if (l == caret.line) - { - caret_shift = caret.column >= n + taken ? -taken : (caret.column > n ? n - caret.column : 0); - } - } - else - { - change.replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n)), token + " " }); - delta = static_cast(token.size()) + 1; - if (l == caret.line && caret.column >= n) - { - caret_shift = static_cast(token.size()) + 1; - } - } - if (l == last) - { - last_delta = delta; - } - } + change.replacements = commentLines(doc, first, last, token, *out); if (anchor != caret) { - change.selects = true; - change.anchor = ALTextPos(first, 0); - change.caret = last + 1 < doc.lineCount() ? ALTextPos(last + 1, 0) : ALTextPos(last, doc.lineLength(last) + last_delta); + // The lines whole. + const ALTextRange lines = wholeLines(doc, ALTextRange(anchor, caret), change.replacements); + change.selects = true; + change.anchor = lines.begin; + change.caret = lines.end; } else { - change.caret = ALTextPos(caret.line, caret.column + caret_shift); + // The caret where it was in its text: pushed along by the token put + // in where it stands, and back to where the token was taken from + // where it stood in it. + change.caret = placedThrough(change.replacements, caret); change.anchor = change.caret; } return change; @@ -271,4 +311,287 @@ std::optional joinLines(const ALTextDocument& doc, S32 first, S32 last, change.caret = doc.lineEnd(first); return change; } + +ALTextPos endOf(const ALTextPos& at, const std::string& text) +{ + const size_t last_break = text.rfind('\n'); + if (last_break == std::string::npos) + { + return ALTextPos(at.line, at.column + static_cast(text.size())); + } + return ALTextPos(at.line + static_cast(std::count(text.begin(), text.end(), '\n')), static_cast(text.size() - last_break - 1)); +} + +ALTextPos placedThrough(const std::vector& replacements, const ALTextPos& pos, bool pushed) +{ + // From the last, so that each is measured on the text as it was before + // any: those before it have not moved yet. + ALTextPos at = pos; + for (auto it = replacements.rbegin(); it != replacements.rend(); ++it) + { + ALTextDocument::Edit edit; + edit.range = it->range.normalised(); + edit.inserted = it->text; + at = edit.placed(at, pushed); + } + return at; +} + +Combined combine(std::vector groups, size_t count) +{ + Combined out; + out.selections.resize(count); + const auto by_begin = [](const Replacement& a, const Replacement& b) { return a.range.normalised().begin < b.range.normalised().begin; }; + for (Group& group : groups) + { + std::stable_sort(group.replacements.begin(), group.replacements.end(), by_begin); + } + // In the order of the text: where a group's first replacement begins, + // or for one that replaces nothing, where its first selection does -- + // before a group that replaces from the same place, its places being + // the text's as it stands. + const auto key = [](const Group& group) { + if (!group.replacements.empty()) + { + return group.replacements.front().range.normalised().begin; + } + ALTextPos first(S32_MAX, S32_MAX); + for (const auto& [index, selection] : group.placed) + { + first = std::min(first, selection.normalised().begin); + } + return first; + }; + std::vector order(groups.size()); + for (size_t i = 0; i < order.size(); ++i) + { + order[i] = i; + } + std::stable_sort(order.begin(), order.end(), [&](size_t a, size_t b) { + const ALTextPos ka = key(groups[a]); + const ALTextPos kb = key(groups[b]); + if (ka != kb) + { + return ka < kb; + } + return groups[a].replacements.empty() && !groups[b].replacements.empty(); + }); + + // Where each replacement kept so far ended, in the text before and in + // the text after: a place at or past one moves by the last of them as + // the place where it ended does (ALTextDocument::Edit's batch). + std::vector> ends; + const auto moved = [&ends](const ALTextPos& pos) { + const auto after = std::upper_bound(ends.begin(), ends.end(), pos, [](const ALTextPos& p, const std::pair& end) { return p < end.first; }); + if (after == ends.begin()) + { + return pos; + } + const auto& [before_end, after_end] = *(after - 1); + if (pos.line == before_end.line) + { + return ALTextPos(after_end.line, after_end.column + (pos.column - before_end.column)); + } + return ALTextPos(pos.line + (after_end.line - before_end.line), pos.column); + }; + for (const size_t g : order) + { + Group& group = groups[g]; + if (!group.replacements.empty() && !ends.empty() && group.replacements.front().range.normalised().begin < ends.back().first) + { + // Over a replacement before it: left out. + continue; + } + // Its selections, by the groups before it, which is all that has + // moved what its own replacements left. + for (const auto& [index, selection] : group.placed) + { + if (index < count) + { + out.selections[index] = ALTextRange(moved(selection.begin), moved(selection.end)); + } + } + for (Replacement& replacement : group.replacements) + { + const ALTextRange range = replacement.range.normalised(); + ends.emplace_back(range.end, endOf(moved(range.begin), replacement.text)); + out.replacements.push_back({ range, std::move(replacement.text) }); + } + } + return out; +} + +std::vector lineRuns(const std::vector& selections, bool touching) +{ + std::vector runs; + for (size_t i = 0; i < selections.size(); ++i) + { + const auto [first, last] = selectedLines(selections[i]); + if (!runs.empty() && first <= runs.back().last + (touching ? 1 : 0)) + { + runs.back().last = std::max(runs.back().last, last); + runs.back().selections.push_back(i); + continue; + } + LineRun run; + run.first = first; + run.last = last; + run.selections.push_back(i); + runs.push_back(std::move(run)); + } + return runs; +} + +std::vector duplicateLines(const ALTextDocument& doc, const std::vector& selections) +{ + std::vector groups; + for (const LineRun& run : lineRuns(selections, false)) + { + Group group; + group.replacements.push_back(duplicated(doc, run.first, run.last)); + // Each selection with the copy. + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, lineShifted(selections[i], run.last - run.first + 1)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector moveLines(const ALTextDocument& doc, const std::vector& selections, S32 direction) +{ + std::vector groups; + const std::vector runs = lineRuns(selections, true); + for (const LineRun& run : runs) + { + if ((direction < 0 && run.first == 0) || (direction > 0 && run.last + 1 >= doc.lineCount())) + { + return {}; + } + } + for (const LineRun& run : runs) + { + std::optional change = moveLinesTo(doc, run.first, run.last, direction < 0 ? run.first - 2 : run.last + 1); + if (!change) + { + continue; + } + Group group; + group.replacements = std::move(change->replacements); + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, lineShifted(selections[i], direction)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector deleteLines(const ALTextDocument& doc, const std::vector& selections) +{ + std::vector groups; + for (const LineRun& run : lineRuns(selections, true)) + { + S32 line = run.first; + S32 length = 0; + Group group; + group.replacements.push_back({ deleted(doc, run.first, run.last, line, length), std::string() }); + // Each caret on the line that took their place, in its column where + // that line has it. + for (const size_t i : run.selections) + { + const ALTextPos caret(line, llmin(selections[i].end.column, length)); + group.placed.emplace_back(i, ALTextRange(caret, caret)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector toggleComment(const ALTextDocument& doc, const std::vector& selections, const std::string& token) +{ + const std::vector runs = lineRuns(selections, false); + // Out where every line that says anything is commented, in all of them. + bool out = true; + bool any_text = false; + for (const LineRun& run : runs) + { + if (const std::optional all = commented(doc, run.first, run.last, token)) + { + any_text = true; + out = out && *all; + } + } + if (!any_text) + { + return {}; + } + std::vector groups; + for (const LineRun& run : runs) + { + Group group; + group.replacements = commentLines(doc, run.first, run.last, token, out); + if (group.replacements.empty()) + { + continue; + } + // A caret where it was in its text; a selection the lines whole. + for (const size_t i : run.selections) + { + const ALTextRange& selection = selections[i]; + if (selection.empty()) + { + const ALTextPos caret = placedThrough(group.replacements, selection.end); + group.placed.emplace_back(i, ALTextRange(caret, caret)); + } + else + { + group.placed.emplace_back(i, wholeLines(doc, selection, group.replacements)); + } + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector joinLines(const ALTextDocument& doc, const std::vector& selections) +{ + // Each selection's lines, or a caret's line and the next, gathered + // where they share a line. + std::vector runs; + for (size_t i = 0; i < selections.size(); ++i) + { + const auto [first, selected] = selectedLines(selections[i]); + const S32 last = llmax(selected, first + 1); + if (!runs.empty() && first <= runs.back().last) + { + runs.back().last = std::max(runs.back().last, last); + runs.back().selections.push_back(i); + continue; + } + LineRun run; + run.first = first; + run.last = last; + run.selections.push_back(i); + runs.push_back(std::move(run)); + } + std::vector groups; + for (const LineRun& run : runs) + { + std::optional change = joinLines(doc, run.first, run.last, false); + if (!change) + { + continue; + } + Group group; + group.replacements = std::move(change->replacements); + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, ALTextRange(change->caret, change->caret)); + } + groups.push_back(std::move(group)); + } + return groups; +} } diff --git a/indra/llui/altextediting.h b/indra/llui/altextediting.h index 5a2800e202..88881d51b3 100644 --- a/indra/llui/altextediting.h +++ b/indra/llui/altextediting.h @@ -90,4 +90,64 @@ namespace ALTextEditing // `keep_blanks`, the break alone, as its gJ. The caret where the first // join is. Nothing where last is not past first. std::optional joinLines(const ALTextDocument& doc, S32 first, S32 last, bool keep_blanks); + + // Where text put in at a place ends: past its last line break, or along + // the place's own line. + ALTextPos endOf(const ALTextPos& at, const std::string& text); + // A place in a text moved by replacements of it, in order and none over + // another, as a mark is (ALTextDocument::Edit::placed): pushed along by + // text put in right at it, unless not `pushed`. + ALTextPos placedThrough(const std::vector& replacements, const ALTextPos& pos, bool pushed = true); + + // --- several selections at once --------------------------------------------- + + // A command done at several selections at once is worked out in groups: + // a selection on its own, or those that share the lines a command over + // whole lines does once. A group says what it replaces, over the text as + // it stands, in order and none over another; and where each selection it + // is for is after -- by its index among the selections the command was + // given, anchor to caret -- in the text as the group's own replacements + // leave it, as a Change says of its one selection. + struct Group + { + std::vector replacements; + std::vector> placed; + }; + // Groups made one change of the text: every replacement in order, and + // each selection where its group leaves it in the text as all of them + // do, the groups before it having moved it along. A group whose + // replacements land over one's before it is left out, and its selections + // with it: those, and any no group placed, are nothing here, for + // whoever makes the change to slide along with the text. + struct Combined + { + std::vector replacements; + std::vector> selections; + }; + Combined combine(std::vector groups, size_t count); + + // Selections, in the order they begin, gathered into runs over the same + // lines -- or over lines next to each other too, where `touching` -- + // each run's lines, first to last, and the selections in it. + struct LineRun + { + S32 first = 0; + S32 last = 0; + std::vector selections; + }; + std::vector lineRuns(const std::vector& selections, bool touching); + + // The commands over whole lines, at several selections in the order + // they begin: each run of them over the same lines done once, each + // selection going as the one selection goes. Moving lines takes runs on + // lines next to each other as one, as does deleting them, and moves + // nothing where any run is at the top or the bottom already; joining + // puts every caret of a run where its first join is. Comments go in, or + // out, the same way for all of them: out where every line any of them + // reaches that says anything is commented. + std::vector duplicateLines(const ALTextDocument& doc, const std::vector& selections); + std::vector moveLines(const ALTextDocument& doc, const std::vector& selections, S32 direction); + std::vector deleteLines(const ALTextDocument& doc, const std::vector& selections); + std::vector toggleComment(const ALTextDocument& doc, const std::vector& selections, const std::string& token); + std::vector joinLines(const ALTextDocument& doc, const std::vector& selections); } diff --git a/indra/llui/altextindent.cpp b/indra/llui/altextindent.cpp index fc400b467f..962a75ee45 100644 --- a/indra/llui/altextindent.cpp +++ b/indra/llui/altextindent.cpp @@ -611,32 +611,65 @@ std::optional reindentPasted(const ALTextDocument& doc, const ALTextPos& // --- whole lines ------------------------------------------------------------------ -Change indentLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, bool in, const Options& options) +namespace { - const auto [first, last] = ALTextEditing::selectedLines(ALTextRange(anchor, caret)); - Change change = shiftLines(doc, first, last, 1, in, options); - // What each line gained or lost at its start. - std::vector delta(static_cast(last - first + 1), 0); - for (const ALTextEditing::Replacement& replacement : change.replacements) + // What each line first through last gained or lost at its start, by + // the replacements of a shift of them. + std::vector shiftOf(const Change& change, S32 first, S32 last, bool in) { - const S32 line = replacement.range.begin.line; - delta[line - first] = in ? static_cast(replacement.text.size()) : -replacement.range.end.column; + std::vector delta(static_cast(last - first + 1), 0); + for (const ALTextEditing::Replacement& replacement : change.replacements) + { + delta[static_cast(replacement.range.begin.line - first)] = in ? static_cast(replacement.text.size()) : -replacement.range.end.column; + } + return delta; } - // The caret and the anchor stay where they were, moved by what their - // lines gained or lost, and never before a line's start. - const auto moved = [&](ALTextPos pos) { - if (pos.line >= first && pos.line <= last) + + // A place where it was, moved by what its line gained or lost, and + // never before the line's start. + ALTextPos movedWith(ALTextPos pos, const std::vector& delta, S32 first) + { + if (pos.line >= first && pos.line < first + static_cast(delta.size())) { - pos.column = llmax(0, pos.column + delta[pos.line - first]); + pos.column = llmax(0, pos.column + delta[static_cast(pos.line - first)]); } return pos; - }; - change.selects = true; - change.anchor = moved(anchor); - change.caret = moved(caret); + } +} + +Change indentLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, bool in, const Options& options) +{ + const auto [first, last] = ALTextEditing::selectedLines(ALTextRange(anchor, caret)); + Change change = shiftLines(doc, first, last, 1, in, options); + const std::vector delta = shiftOf(change, first, last, in); + change.selects = true; + change.anchor = movedWith(anchor, delta, first); + change.caret = movedWith(caret, delta, first); return change; } +std::vector indentLines(const ALTextDocument& doc, const std::vector& selections, bool in, const Options& options) +{ + std::vector groups; + for (const ALTextEditing::LineRun& run : ALTextEditing::lineRuns(selections, false)) + { + Change change = shiftLines(doc, run.first, run.last, 1, in, options); + if (change.replacements.empty()) + { + continue; + } + const std::vector delta = shiftOf(change, run.first, run.last, in); + ALTextEditing::Group group; + group.replacements = std::move(change.replacements); + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, ALTextRange(movedWith(selections[i].begin, delta, run.first), movedWith(selections[i].end, delta, run.first))); + } + groups.push_back(std::move(group)); + } + return groups; +} + Change shiftLines(const ALTextDocument& doc, S32 first, S32 last, S32 levels, bool in, const Options& options) { levels = llmax(1, levels); diff --git a/indra/llui/altextindent.h b/indra/llui/altextindent.h index 85b7e50fdb..70d8118ce5 100644 --- a/indra/llui/altextindent.h +++ b/indra/llui/altextindent.h @@ -178,6 +178,11 @@ namespace ALTextIndent // Each line a level in, where it has anything on it, or out by what a // level is; the caret and the anchor moved with their lines' starts. Change indentLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, bool in, const Options& options); + // The same at several selections in the order they begin: each run of + // them over the same lines a level in or out once, and each selection + // moved with its lines as the one is. + std::vector indentLines(const ALTextDocument& doc, const std::vector& selections, bool in, + const Options& options); // Lines first through last so many levels in, those that are not // empty -- a tab a level, or a tab's width of spaces where tabs are // soft -- or out, each level a tab or up to a tab's width of spaces: diff --git a/indra/llui/altextundo.cpp b/indra/llui/altextundo.cpp index 974d7d5ce1..8556d1deeb 100644 --- a/indra/llui/altextundo.cpp +++ b/indra/llui/altextundo.cpp @@ -96,6 +96,29 @@ namespace // counts, so that a history of many small edits is held to it too. constexpr size_t STEP_WRITTEN = 48; constexpr size_t EDIT_WRITTEN = 16; + // And each other selection a step keeps, before or after. + constexpr size_t RANGE_WRITTEN = 20; + + LLSD rangesAsLLSD(const std::vector& ranges) + { + LLSD out = LLSD::emptyArray(); + for (const ALTextRange& range : ranges) + { + out.append(LLSD::emptyArray().with(0, range.begin.line).with(1, range.begin.column).with(2, range.end.line).with(3, range.end.column)); + } + return out; + } + + std::vector rangesFrom(const LLSD& sd) + { + std::vector out; + for (LLSD::array_const_iterator it = sd.beginArray(); it != sd.endArray(); ++it) + { + const LLSD& one = *it; + out.emplace_back(ALTextPos(one[0].asInteger(), one[1].asInteger()), ALTextPos(one[2].asInteger(), one[3].asInteger())); + } + return out; + } // An edit as it is written: where it begins and ends, what it took and // what it put -- an array rather than a map, since a history is mostly @@ -249,6 +272,8 @@ void ALTextUndo::join(Step& last, Step&& next) } last.caretAfter = next.caretAfter; last.anchorAfter = next.anchorAfter; + last.bytes = last.bytes - RANGE_WRITTEN * last.othersAfter.size() + RANGE_WRITTEN * next.othersAfter.size(); + last.othersAfter = std::move(next.othersAfter); last.written.clear(); } @@ -270,6 +295,21 @@ void ALTextUndo::settle(const ALTextRange& selection) newest.written.clear(); } +void ALTextUndo::settle(const ALTextRange& selection, std::vector others) +{ + if (!mSettling || mSteps.undone().empty()) + { + return; + } + settle(selection); + Step& newest = mSteps.newest(); + // What the others weigh, with the step and with the steps back. + const size_t was = newest.bytes; + newest.bytes = newest.bytes - RANGE_WRITTEN * newest.othersAfter.size() + RANGE_WRITTEN * others.size(); + newest.othersAfter = std::move(others); + mUndoneBytes = mUndoneBytes - was + newest.bytes; +} + void ALTextUndo::label(std::string_view text) { ++mRevision; @@ -281,7 +321,8 @@ void ALTextUndo::label(std::string_view text) mSteps.label(text); } -void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& before_in, const ALTextPos& after, F64 now) +void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& before_in, const ALTextPos& after, F64 now, + std::vector others_before) { if (edit.nothing()) { @@ -304,8 +345,9 @@ void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& bef step.anchorBefore = before_in.begin; step.caretAfter = after; step.anchorAfter = after; + step.othersBefore = std::move(others_before); step.serial = ++mNextSerial; - step.bytes = STEP_WRITTEN + EDIT_WRITTEN + edit.removed.size() + edit.inserted.size(); + step.bytes = STEP_WRITTEN + EDIT_WRITTEN + edit.removed.size() + edit.inserted.size() + RANGE_WRITTEN * step.othersBefore.size(); step.typed = mTypingDepth > 0 && !mSteps.inGroup(); // The key a run is joined by: the kind of change, where it carries on @@ -319,7 +361,7 @@ void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& bef // the key was typed where the run left the caret, or its text // ended, with nothing selected; the rest of what the key does is // one with the first, at the same moment. - key = keyOf(Kind::Typing); + key = keyOf(mTypingErases ? Kind::Erasing : Kind::Typing); if (!mTypingNoted) { mTypingNoted = true; @@ -345,12 +387,13 @@ void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& bef forgetOverBudget(); } -void ALTextUndo::beginTyping(const ALTextRange& selection) +void ALTextUndo::beginTyping(const ALTextRange& selection, bool erasing) { if (mTypingDepth++ == 0) { - mTypingAt = selection; - mTypingNoted = false; + mTypingAt = selection; + mTypingNoted = false; + mTypingErases = erasing; } } @@ -434,7 +477,7 @@ void ALTextUndo::closeGroups() forgetOverBudget(); } -std::optional ALTextUndo::undo() +std::optional ALTextUndo::undo(std::vector* others) { mResumeStep = 0; std::optional step = mSteps.takeUndo(); @@ -451,11 +494,15 @@ std::optional ALTextUndo::undo() mDocument.replace(back.range, back.inserted, back.parts); } const ALTextRange selection(step->anchorBefore, step->caretBefore); + if (others) + { + *others = step->othersBefore; + } mSteps.pushRedo(std::move(*step)); return selection; } -std::optional ALTextUndo::redo() +std::optional ALTextUndo::redo(std::vector* others) { mResumeStep = 0; std::optional step = mSteps.takeRedo(); @@ -471,6 +518,10 @@ std::optional ALTextUndo::redo() mDocument.replace(edit.range, edit.inserted, edit.parts); } const ALTextRange selection(step->anchorAfter, step->caretAfter); + if (others) + { + *others = step->othersAfter; + } mSteps.pushUndo(std::move(*step)); return selection; } @@ -513,6 +564,15 @@ namespace { out["anchor_after"] = posAsLLSD(step.anchorAfter); } + // The other selections only where there were any. + if (!step.othersBefore.empty()) + { + out["others_before"] = rangesAsLLSD(step.othersBefore); + } + if (!step.othersAfter.empty()) + { + out["others_after"] = rangesAsLLSD(step.othersAfter); + } LLSD edits = LLSD::emptyArray(); std::optional pending; for (const ALTextDocument::Edit& edit : step.edits) @@ -660,7 +720,9 @@ std::optional ALTextUndo::historyFrom(const LLSD& sd, std:: step.caretAfter = posFrom(one["after"]); step.anchorBefore = one.has("anchor_before") ? posFrom(one["anchor_before"]) : step.caretBefore; step.anchorAfter = one.has("anchor_after") ? posFrom(one["anchor_after"]) : step.caretAfter; - step.bytes = STEP_WRITTEN; + step.othersBefore = rangesFrom(one["others_before"]); + step.othersAfter = rangesFrom(one["others_after"]); + step.bytes = STEP_WRITTEN + RANGE_WRITTEN * (step.othersBefore.size() + step.othersAfter.size()); for (LLSD::array_const_iterator it = one["edits"].beginArray(); it != one["edits"].endArray(); ++it) { ALTextDocument::Edit edit; diff --git a/indra/llui/altextundo.h b/indra/llui/altextundo.h index 3555144aae..069f1382ad 100644 --- a/indra/llui/altextundo.h +++ b/indra/llui/altextundo.h @@ -36,7 +36,8 @@ // A document's edits as steps a person can take back: each one the edits // it was made of, in order, with the selection -- its anchor and its caret -// -- as it stood before and after. +// -- as it stood before and after, and the other selections a view with +// several had besides it. // A run of typing is one step -- characters typed one after the other, each // where the last ended, within a moment of each other -- and so is a run of // backspaces or deletes; anything recorded while a group is open is one @@ -61,6 +62,10 @@ class ALTextUndo // where nothing was selected. ALTextPos anchorBefore; ALTextPos anchorAfter; + // The selections besides that one, each anchor to caret + // (ALTextCarets): none where there was only the one. + std::vector othersBefore; + std::vector othersAfter; std::string mLabel; // Which step this is, for a save point to find it by; a run // joined to it keeps the number it began with. @@ -100,16 +105,20 @@ class ALTextUndo explicit ALTextUndo(ALTextDocument& document); // A change made through the document, with where the caret was and - // is -- or the selection it was made over, anchor to caret. `now` is in - // seconds from any clock, and the window is how close two changes have - // to be to join a run. + // is -- or the selection it was made over, anchor to caret, and the + // others beside it. `now` is in seconds from any clock, and the window + // is how close two changes have to be to join a run. void record(const ALTextDocument::Edit& edit, const ALTextPos& before, const ALTextPos& after, F64 now); - void record(const ALTextDocument::Edit& edit, const ALTextRange& before, const ALTextPos& after, F64 now); + void record(const ALTextDocument::Edit& edit, const ALTextRange& before, const ALTextPos& after, F64 now, + std::vector others_before = {}); // Where the selection ends up once the change that was just recorded is // done -- a replace-all putting the caret back where it was, a line - // moved with its selection -- for a redo to put it there. Nothing once - // a step has been taken back or forward since. + // moved with its selection -- for a redo to put it there; and the others + // with it, where they are given, else as they were last said. Until + // they are said, a redo leaves only the caret. Nothing once a step has + // been taken back or forward since. void settle(const ALTextRange& selection); + void settle(const ALTextRange& selection, std::vector others); void setRunWindow(F64 seconds) { mWindow = seconds; } // What one key typed does -- a character, a pair and its closer, a @@ -117,8 +126,10 @@ class ALTextUndo // before it where the key was typed, nothing selected, where the run // left the caret or its text, within the window; else it starts a // run. Given the selection the key was typed at. Scopes nest, as a - // key's handlers do; a group open takes precedence. - void beginTyping(const ALTextRange& selection); + // key's handlers do; a group open takes precedence. A key that erases + // -- Backspace at several carets, which is one edit of many stretches + // and so no run of its own kind -- is a run of erasing keys instead. + void beginTyping(const ALTextRange& selection, bool erasing = false); void endTyping(); // Everything recorded until endGroup() is one step. @@ -141,9 +152,10 @@ class ALTextUndo bool inGroup() const { return mSteps.inGroup(); } // The step back and the step forward, applied. The selection it puts - // back, anchor to caret, or nothing where there was nothing to do. - std::optional undo(); - std::optional redo(); + // back, anchor to caret, or nothing where there was nothing to do; and + // the others it puts back beside it, where they are asked for. + std::optional undo(std::vector* others = nullptr); + std::optional redo(std::vector* others = nullptr); bool canUndo() const { return mSteps.canUndo(); } bool canRedo() const { return mSteps.canRedo(); } // Nothing to step back or forward to, and the text as it stands taken @@ -256,6 +268,7 @@ class ALTextUndo // for has recorded an edit yet: the rest of what it does joins that. S32 mTypingDepth = 0; bool mTypingNoted = false; + bool mTypingErases = false; // The selection the key was typed at, anchor to caret. ALTextRange mTypingAt; // What the steps back weigh together, kept as they change rather than diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index 1e48080b44..0c61961e8c 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -175,6 +175,12 @@ namespace case ALEditorCommand::ShrinkSelection: case ALEditorCommand::SelectNextOccurrence: case ALEditorCommand::ChangeAllOccurrences: + case ALEditorCommand::AddCaretAbove: + case ALEditorCommand::AddCaretBelow: + case ALEditorCommand::ColumnSelectLeft: + case ALEditorCommand::ColumnSelectRight: + case ALEditorCommand::ColumnSelectUp: + case ALEditorCommand::ColumnSelectDown: case ALEditorCommand::FindNext: case ALEditorCommand::FindPrevious: case ALEditorCommand::COUNT: @@ -421,6 +427,9 @@ void ALTextView::setText(std::string_view text) mPreeditSegmentEnds.clear(); mPreeditStandouts.clear(); mPreeditOverwritten.clear(); + // One caret in a new text, before the edit that puts it in slides the + // others to its start for nothing. + mCarets.clear(); mDocument.setText(text); mUndo.clear(); // A new text has no changes to go back to. @@ -899,15 +908,44 @@ void ALTextView::placeSelection(const ALTextPos& anchor, const ALTextPos& caret) const ALTextRange was = selection(); mAnchor = mDocument.clamp(anchor); mCaret = snapped(mDocument.clamp(caret), was.end); - if (mLayout.hidden(mCaret.line)) + // The others it now meets become part of it. + if (!mCarets.empty()) { + ALTextRange main(mAnchor, mCaret); + if (mCarets.merge(main)) + { + mAnchor = main.begin; + mCaret = main.end; + } + } + // A caret on a line folded away is not drawn: its line is shown, the + // others' as the main one's, where any line is hidden at all. + const auto reveal = [this](S32 line) { + if (!mLayout.hidden(line)) + { + return; + } if (mFeatures) { - mFeatures->revealLine(mCaret.line); + mFeatures->revealLine(line); } else { - mLayout.setHidden(mCaret.line, mCaret.line, false); + mLayout.setHidden(line, line, false); + } + }; + reveal(mCaret.line); + if (!mCarets.empty() && mLayout.anyHidden()) + { + std::vector lines; + lines.reserve(mCarets.size()); + for (const ALTextRange& other : mCarets) + { + lines.push_back(other.end.line); + } + for (const S32 line : lines) + { + reveal(line); } } mBlink.reset(); @@ -956,6 +994,275 @@ void ALTextView::setSelection(const ALTextRange& range) scrollToCaret(); } +// --- several carets ------------------------------------------------------------ + +std::vector ALTextView::clamped(std::vector selections) const +{ + for (ALTextRange& one : selections) + { + one = ALTextRange(mDocument.clamp(one.begin), mDocument.clamp(one.end)); + } + return selections; +} + +void ALTextView::addSelection(const ALTextRange& range) +{ + mCarets.add(ALTextRange(mDocument.clamp(range.begin), mDocument.clamp(range.end))); + // Put where it is again, to take in the new one where they meet. + placeSelection(mAnchor, mCaret); +} + +void ALTextView::setSelections(const ALTextRange& main, std::vector others) +{ + mCarets.assign(clamped(std::move(others))); + setSelection(main); +} + +bool ALTextView::singleSelection() +{ + if (mCarets.empty()) + { + return false; + } + mCarets.clear(); + mBlink.reset(); + return true; +} + +void ALTextView::toggleCaret(const ALTextPos& pos) +{ + const ALTextPos at = mDocument.clamp(pos); + size_t main = 0; + std::vector all = selectionsInOrder(&main); + // The one there -- a caret at it, or a selection over it -- taken + // away, but for the last; the main one, the one before it then. + for (size_t i = 0; i < all.size(); ++i) + { + const ALTextRange range = all[i].normalised(); + if (range.begin <= at && at <= range.end) + { + if (all.size() == 1) + { + return; + } + all.erase(all.begin() + static_cast(i)); + placeSelections(all, main == i ? (i > 0 ? i - 1 : 0) : (main > i ? main - 1 : main)); + return; + } + } + all.emplace_back(at, at); + placeSelections(all, all.size() - 1); +} + +bool ALTextView::addCarets(S32 direction) +{ + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + // Each caret's x where a motion or this left them, else where it is: + // a column grown past a short line keeps to its column. + std::vector xs = desiredXs(all); + std::vector placed = all; + for (size_t i = 0; i < all.size(); ++i) + { + F32 x = xs[i]; + const ALTextPos to = rowsFrom(all[i].end, direction, x); + xs[i] = x >= 0.f ? x : xs[i]; + // Past the first or the last row there is nothing to add. + if (x >= 0.f) + { + placed.emplace_back(to, to); + xs.push_back(x); + } + } + if (placed.size() == all.size()) + { + return false; + } + // The furthest that way the main one, for the view to follow. + size_t furthest = 0; + for (size_t i = 1; i < placed.size(); ++i) + { + if (direction < 0 ? placed[i].end < placed[furthest].end : placed[furthest].end < placed[i].end) + { + furthest = i; + } + } + placeSelections(placed, furthest); + // Each keeps its x, those that met another the first's. + std::vector now = selectionsInOrder(); + std::vector now_xs(now.size(), -1.f); + for (size_t i = 0; i < now.size(); ++i) + { + for (size_t k = 0; k < placed.size(); ++k) + { + if (placed[k].end == now[i].end) + { + now_xs[i] = xs[k]; + break; + } + } + } + mEachDesired.selections = std::move(now); + mEachDesired.xs = std::move(now_xs); + mEachDesired.under = laidOut(); + mDesiredX = xs[furthest]; + scrollToCaret(); + return true; +} + +ALTextView::ColumnCorner ALTextView::cornerAt(const ALTextPos& pos) +{ + ColumnCorner corner; + corner.line = pos.line; + corner.x = mLayout.xOf(pos.line, pos.column, &corner.row); + return corner; +} + +ALTextView::ColumnCorner ALTextView::cornerAtLocal(S32 x, S32 y) +{ + const LLRect text = textRect(); + ColumnCorner corner; + if (mLayout.rowHeight() <= 0 || mDocument.lineCount() == 0) + { + return corner; + } + const S32 doc_y = (text.mTop - y) + mScrollY; + corner.line = mLayout.lineAtY(llmax(0, doc_y)); + corner.row = mLayout.rowAtY(corner.line, doc_y - mLayout.lineTop(corner.line)); + corner.x = llmax(0.f, static_cast(x - text.mLeft) + mScrollX); + return corner; +} + +bool ALTextView::stepRow(S32& line, S32& row, S32 direction) +{ + if (direction < 0) + { + if (row > 0) + { + --row; + return true; + } + const S32 above = mLayout.visibleFrom(line - 1, -1); + if (above < 0) + { + return false; + } + line = above; + row = mLayout.rowCount(line) - 1; + return true; + } + if (row + 1 < mLayout.rowCount(line)) + { + ++row; + return true; + } + const S32 below = mLayout.visibleFrom(line + 1, 1); + if (below < 0) + { + return false; + } + line = below; + row = 0; + return true; +} + +ALTextView::LaidOut ALTextView::laidOut() +{ + return { mLayout.wrapWidth(), mLayout.columnWidth() }; +} + +std::vector ALTextView::desiredXs(const std::vector& all) +{ + return mEachDesired.selections == all && mEachDesired.under == laidOut() ? mEachDesired.xs : std::vector(all.size(), -1.f); +} + +std::pair ALTextView::columnCorners() +{ + // Its own corners while it stands as it was put, in the layout it was + // put in: zoomed or wrapped again since, its x's and rows are another + // layout's. + if (!mColumn.selections.empty() && selectionsInOrder() == mColumn.selections && mColumn.under == laidOut()) + { + return { mColumn.from, mColumn.to }; + } + return { cornerAt(mAnchor), cornerAt(mCaret) }; +} + +void ALTextView::selectColumn(const ColumnCorner& from, const ColumnCorner& to) +{ + const bool down = from.line < to.line || (from.line == to.line && from.row <= to.row); + // From the higher corner's row down to the lower's, each row's columns + // at the two x's, nearest as drawn: past a row's end, its end. + S32 line = down ? from.line : to.line; + S32 row = down ? from.row : to.row; + const ColumnCorner& last = down ? to : from; + std::vector rows; + while (true) + { + row = llclamp(row, 0, llmax(0, mLayout.rowCount(line) - 1)); + rows.emplace_back(ALTextPos(line, mLayout.columnAt(line, row, from.x, true)), ALTextPos(line, mLayout.columnAt(line, row, to.x, true))); + if (line > last.line || (line == last.line && row >= last.row) || !stepRow(line, row, 1)) + { + break; + } + } + placeSelections(rows, down ? rows.size() - 1 : 0); + mColumn.from = from; + mColumn.to = to; + mColumn.selections = selectionsInOrder(); + mColumn.under = laidOut(); + // Each caret keeps the column's x between rows, as carets added above + // and below do, so that moving them keeps the column past short rows. + mEachDesired.selections = mColumn.selections; + mEachDesired.xs.assign(mColumn.selections.size(), to.x); + mEachDesired.under = mColumn.under; + mDesiredX = to.x; + scrollToCaret(); +} + +bool ALTextView::growColumn(S32 columns, S32 rows) +{ + if (mLayout.rowHeight() <= 0 || mDocument.lineCount() == 0) + { + return false; + } + auto [from, to] = columnCorners(); + if (rows != 0 && !stepRow(to.line, to.row, rows)) + { + return false; + } + if (columns < 0 && to.x <= 0.f && rows == 0) + { + return false; + } + // A column is a space's advance: a tab or a wide character is crossed + // in as many steps as it is drawn wide, its edge the nearest. + to.x = llmax(0.f, to.x + static_cast(columns) * mLayout.columnWidth()); + selectColumn(from, to); + return true; +} + +S32 ALTextView::selectAllMatches() +{ + settleFind(); + const std::vector& matches = mFind.matches(); + if (matches.empty()) + { + return 0; + } + // Every match selected, the current one the main one, and the + // keyboard to the text to type over them. + const S32 current = llclamp(mFind.current(), 0, static_cast(matches.size()) - 1); + placeSelections(std::vector(matches.begin(), matches.end()), static_cast(current)); + mDesiredX = -1.f; + scrollToCaret(); + if (mTakesFocus) + { + setFocus(true); + } + return static_cast(matches.size()); +} + std::string ALTextView::wordBeforeCaret() const { const std::string& line = mDocument.line(mCaret.line); @@ -1782,8 +2089,10 @@ void ALTextView::onDocumentEdit(const ALTextDocument::Edit& edit) // drawn; the ones below slide. mSpelling.edited(edit, mDocument.lineCount()); mSpellTimer.reset(); - // What the find bar found slides with the text. + // What the find bar found slides with the text, and so do the carets + // besides the main one, which whoever made the edit puts. mFind.edited(edit); + mCarets.apply(edit); // The layers: what is after the edit slides with the text, what it // cut through goes. @@ -1821,60 +2130,146 @@ void ALTextView::onDocumentEdit(const ALTextDocument::Edit& edit) } } -void ALTextView::moveVertically(S32 rows, bool extend) +ALTextPos ALTextView::rowsFrom(const ALTextPos& from, S32 rows, F32& desired_x) { - const S32 row_h = mLayout.rowHeight(); - if (row_h <= 0) + if (mLayout.rowHeight() <= 0) { - return; + return from; } S32 row; - const F32 x = mLayout.xOf(mCaret.line, mCaret.column, &row); - if (mDesiredX < 0.f) + const F32 x = mLayout.xOf(from.line, from.column, &row); + if (desired_x < 0.f) { - mDesiredX = x; + desired_x = x; } - S32 line = mCaret.line; - for (; rows < 0; ++rows) + S32 line = from.line; + for (; rows != 0; rows += rows < 0 ? 1 : -1) { - if (row > 0) - { - --row; - } - else if (const S32 above = mLayout.visibleFrom(line - 1, -1); above >= 0) - { - line = above; - row = mLayout.rowCount(line) - 1; - } - else + if (!stepRow(line, row, rows)) { - placeCaret(mDocument.start(), extend); - mDesiredX = -1.f; - scrollToCaret(); - return; + desired_x = -1.f; + return rows < 0 ? mDocument.start() : mDocument.end(); } } - for (; rows > 0; --rows) + return ALTextPos(line, mLayout.columnAt(line, row, desired_x, true)); +} + +namespace +{ + bool isMotion(ALEditorCommand command) + { + typedef ALEditorCommand C; + return (command >= C::MoveLeft && command <= C::MovePageDown) || (command >= C::SelectLeft && command <= C::SelectPageDown) || + (command >= C::MoveSubwordLeft && command <= C::SelectSubwordRight); + } + + bool isVertical(ALEditorCommand command) { - if (row + 1 < mLayout.rowCount(line)) - { - ++row; - } - else if (const S32 below = mLayout.visibleFrom(line + 1, 1); below >= 0) + typedef ALEditorCommand C; + switch (command) { - line = below; - row = 0; + case C::MoveUp: + case C::MoveDown: + case C::MovePageUp: + case C::MovePageDown: + case C::SelectUp: + case C::SelectDown: + case C::SelectPageUp: + case C::SelectPageDown: + return true; + default: + return false; } - else + } +} + +ALTextRange ALTextView::moved(ALEditorCommand command, const ALTextRange& selection, F32& desired_x) +{ + typedef ALEditorCommand C; + const bool extend = (command >= C::SelectLeft && command <= C::SelectPageDown) || command == C::SelectSubwordLeft || + command == C::SelectSubwordRight; + const ALTextPos& caret = selection.end; + const ALTextRange range = selection.normalised(); + // An arrow with a selection and no shift collapses it to that end. + const bool collapse = !extend && !range.empty(); + ALTextPos to = caret; + switch (command) + { + case C::MoveLeft: + case C::SelectLeft: + to = collapse ? range.begin : mDocument.prevCluster(caret); + break; + case C::MoveRight: + case C::SelectRight: + to = collapse ? range.end : mDocument.nextCluster(caret); + break; + case C::MoveUp: + case C::SelectUp: + to = rowsFrom(caret, -1, desired_x); + break; + case C::MoveDown: + case C::SelectDown: + to = rowsFrom(caret, 1, desired_x); + break; + case C::MovePageUp: + case C::SelectPageUp: + to = rowsFrom(caret, -rowsPerPage(), desired_x); + break; + case C::MovePageDown: + case C::SelectPageDown: + to = rowsFrom(caret, rowsPerPage(), desired_x); + break; + case C::MoveWordLeft: + case C::SelectWordLeft: + to = wordStep(caret, false, false); + break; + case C::MoveWordRight: + case C::SelectWordRight: + to = wordStep(caret, true, false); + break; + case C::MoveSubwordLeft: + case C::SelectSubwordLeft: + to = wordStep(caret, false, true); + break; + case C::MoveSubwordRight: + case C::SelectSubwordRight: + to = wordStep(caret, true, true); + break; + case C::MoveLineStart: + case C::SelectLineStart: { - placeCaret(mDocument.end(), extend); - mDesiredX = -1.f; - scrollToCaret(); - return; + // To the first thing on the line, or to the line's start from + // there. + const std::string& line = mDocument.line(caret.line); + S32 indent = 0; + while (indent < static_cast(line.size()) && (line[indent] == ' ' || line[indent] == '\t')) + { + ++indent; + } + to = ALTextPos(caret.line, caret.column == indent ? 0 : indent); + break; } + case C::MoveLineEnd: + case C::SelectLineEnd: + to = mDocument.lineEnd(caret.line); + break; + case C::MoveDocStart: + case C::SelectDocStart: + to = mDocument.start(); + break; + case C::MoveDocEnd: + case C::SelectDocEnd: + to = mDocument.end(); + break; + default: + return selection; } - placeCaret(ALTextPos(line, mLayout.columnAt(line, row, mDesiredX, true)), extend); - scrollToCaret(); + if (!isVertical(command)) + { + desired_x = -1.f; + } + to = snapped(mDocument.clamp(to), caret); + return ALTextRange(extend ? selection.begin : to, to); } // --- editing ------------------------------------------------------------------- @@ -1910,14 +2305,15 @@ ALTextDocument::Edit ALTextView::edit(const ALTextRange& range_in, std::string_v range = withoutComposition(range_in); resetPreedit(); } - const ALTextRange before = selection(); - ALTextDocument::Edit done = mDocument.replace(range, fitting(range, text)); + const ALTextRange before = selection(); + std::vector others = mCarets.selections(); + ALTextDocument::Edit done = mDocument.replace(range, fitting(range, text)); if (done.nothing()) { return done; } const ALTextPos after = mDocument.clamp(done.endAfter()); - mUndo.record(done, before, after, LLTimer::getElapsedSeconds()); + mUndo.record(done, before, after, LLTimer::getElapsedSeconds(), std::move(others)); placeCaret(after, false); return done; } @@ -1936,14 +2332,15 @@ ALTextDocument::Edit ALTextView::editMany(std::vector others = mCarets.selections(); + ALTextDocument::Edit done = mDocument.replaceMany(std::move(edits)); if (done.nothing()) { return done; } const ALTextPos after = mDocument.clamp(caret); - mUndo.record(done, before, after, LLTimer::getElapsedSeconds()); + mUndo.record(done, before, after, LLTimer::getElapsedSeconds(), std::move(others)); placeCaret(after, false); return done; } @@ -2002,9 +2399,9 @@ void ALTextView::full() void ALTextView::afterEdit() { editsDone(); - // Where the change left the selection, for a redo to put it back + // Where the change left the selections, for a redo to put them back // there; nothing once a step has been taken back or forward. - mUndo.settle(selection()); + mUndo.settle(selection(), mCarets.selections()); mDesiredX = -1.f; mChangedSinceFocus = true; mBlink.reset(); @@ -2110,93 +2507,639 @@ bool ALTextView::replaceAll(std::vector> edi { return false; } - placeCaret(mDocument.clamp(caret), false); - afterEdit(); - return true; -} - -void ALTextView::goTo(const ALTextPos& pos) -{ - setCaret(mDocument.clamp(pos)); -} - -void ALTextView::goTo(const ALTextRange& range) -{ - setSelection(ALTextRange(mDocument.clamp(range.begin), mDocument.clamp(range.end))); + placeCaret(mDocument.clamp(caret), false); + afterEdit(); + return true; +} + +void ALTextView::goTo(const ALTextPos& pos) +{ + singleSelection(); + setCaret(mDocument.clamp(pos)); +} + +void ALTextView::goTo(const ALTextRange& range) +{ + singleSelection(); + setSelection(ALTextRange(mDocument.clamp(range.begin), mDocument.clamp(range.end))); +} + +void ALTextView::deleteRange(const ALTextRange& range) +{ + if (mReadOnly) + { + return; + } + if (!edit(range, std::string_view()).nothing()) + { + afterEdit(); + } +} + +// --- line commands and indentation ---------------------------------------------- + +ALTextPos ALTextView::wordStep(const ALTextPos& from, bool forward, bool parts) const +{ + // Code by its names and marks, as a double-click takes a name; prose, + // and a text with no grammar, by its words. + const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); + if (parts || (grammar && !grammar->prose())) + { + return forward ? mDocument.nextCodeWord(from, parts) : mDocument.prevCodeWord(from, parts); + } + return forward ? mDocument.nextWord(from) : mDocument.prevWord(from); +} + +ALTextIndent::Options ALTextView::editingOptions() const +{ + ALTextIndent::Options options; + options.tabWidth = mTabWidth; + options.softTabs = mSoftTabs; + return options; +} + +ALTextIndent::opener_t ALTextView::openerOf() +{ + return [this](const ALTextPos& closer, ALTextPos& opener) { return mFeatures && mFeatures->closerOpenedAt(closer, opener); }; +} + +void ALTextView::apply(const ALTextEditing::Change& change) +{ + // One edit however many lines it touches: an indent of every line is + // one notification, not one a line. + std::vector> edits; + edits.reserve(change.replacements.size()); + for (const ALTextEditing::Replacement& one : change.replacements) + { + edits.emplace_back(one.range, one.text); + } + // Not made at all, the caret left where it was, where the text has + // no room for it. + if (!fits(edits)) + { + return; + } + mUndo.beginGroup(); + editMany(std::move(edits), change.caret); + mUndo.endGroup(); + if (change.selects) + { + placeSelection(change.anchor, change.caret); + } + else + { + placeCaret(change.caret, false); + } + afterEdit(); +} + +std::string ALTextView::tabText(const ALTextPos& at) const +{ + return ALTextIndent::tabText(mDocument, at, editingOptions()); +} + +bool ALTextView::commentBefore(const ALTextPos& at) +{ + if (!mHighlighter.grammar() || at.column <= 0) + { + return false; + } + for (const ALSyntaxToken& token : mHighlighter.tokens(at.line)) + { + if (token.begin < at.column && at.column <= token.end) + { + return token.kind == ALSyntaxKind::Comment || token.kind == ALSyntaxKind::DocComment; + } + } + return false; +} + +// --- at every selection ------------------------------------------------------------ + +std::vector ALTextView::selectionsInOrder(size_t* main) const +{ + const ALTextRange mine = selection(); + const ALTextPos begin = mine.normalised().begin; + const std::vector& others = mCarets.selections(); + // Where the main one goes among the others: none of them begins where + // it does, or they would be one. + const auto at = std::lower_bound(others.begin(), others.end(), begin, + [](const ALTextRange& other, const ALTextPos& p) { return other.normalised().begin < p; }); + std::vector all; + all.reserve(others.size() + 1); + all.insert(all.end(), others.begin(), at); + if (main) + { + *main = all.size(); + } + all.push_back(mine); + all.insert(all.end(), at, others.end()); + return all; +} + +bool ALTextView::anySelected() const +{ + return hasSelection() || std::any_of(mCarets.begin(), mCarets.end(), [](const ALTextRange& other) { return !other.empty(); }); +} + +void ALTextView::placeSelections(const std::vector& selections, size_t main) +{ + std::vector others; + others.reserve(selections.size()); + for (size_t i = 0; i < selections.size(); ++i) + { + if (i != main) + { + others.push_back(selections[i]); + } + } + mCarets.assign(clamped(std::move(others))); + placeSelection(selections[main].begin, selections[main].end); +} + +bool ALTextView::applyGroups(const std::vector& selections, size_t main, std::vector groups) +{ + ALTextEditing::Combined combined = ALTextEditing::combine(std::move(groups), selections.size()); + std::vector> edits; + edits.reserve(combined.replacements.size()); + for (ALTextEditing::Replacement& one : combined.replacements) + { + edits.emplace_back(one.range, std::move(one.text)); + } + // Made whole or not at all: a change of several stretches that the text + // has no room for is not made. + ALTextDocument::Edit done; + if (!edits.empty()) + { + if (!fits(edits)) + { + return false; + } + done = editMany(std::move(edits), combined.selections[main] ? combined.selections[main]->end : mCaret); + } + std::vector placed(selections.size()); + for (size_t i = 0; i < selections.size(); ++i) + { + if (combined.selections[i]) + { + placed[i] = *combined.selections[i]; + } + else + { + placed[i] = done.nothing() ? selections[i] : ALTextCarets::slid(selections[i], done); + } + } + if (!edits.empty() && done.nothing()) + { + // Nothing changed: each where it was. + return false; + } + placeSelections(placed, main); + if (done.nothing()) + { + mDesiredX = -1.f; + scrollToCaret(); + return false; + } + afterEdit(); + return true; +} + +bool ALTextView::editEach(const each_change_t& change) +{ + // The steps are of the text without a composition in it, and so are + // the selections each is worked out at. + if (hasPreedit()) + { + resetPreedit(); + } + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + std::vector groups; + for (size_t i = 0; i < all.size(); ++i) + { + std::optional one = change(i, all[i]); + if (!one) + { + continue; + } + ALTextEditing::Group group; + group.replacements = std::move(one->replacements); + group.placed.emplace_back(i, one->selects ? ALTextRange(one->anchor, one->caret) : ALTextRange(one->caret, one->caret)); + groups.push_back(std::move(group)); + } + return applyGroups(all, main, std::move(groups)); +} + +bool ALTextView::editGroups(const groups_t& groups) +{ + if (hasPreedit()) + { + resetPreedit(); + } + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + mUndo.beginGroup(); + const bool done = applyGroups(all, main, groups(all)); + mUndo.endGroup(); + return done; +} + +std::optional ALTextView::erasedBy(ALEditorCommand command, const ALTextRange& selection) const +{ + typedef ALEditorCommand C; + if (!selection.empty()) + { + return selection.normalised(); + } + const ALTextPos& at = selection.end; + switch (command) + { + case C::DeleteLeft: + if (at != mDocument.start()) + { + // In a line's indentation, a level's spaces at once. + const std::optional level = ALTextIndent::backspaceFrom(mDocument, at, editingOptions()); + return ALTextRange(level ? *level : mDocument.prevCluster(at), at); + } + break; + case C::DeleteRight: + if (at != mDocument.end()) + { + return ALTextRange(at, mDocument.nextCluster(at)); + } + break; + case C::DeleteWordLeft: + return ALTextRange(wordStep(at, false, false), at); + case C::DeleteWordRight: + return ALTextRange(at, wordStep(at, true, false)); + case C::DeleteToLineStart: + // Back to the line's start, or the break before it from there. + if (at.column > 0) + { + return ALTextRange(ALTextPos(at.line, 0), at); + } + if (at != mDocument.start()) + { + return ALTextRange(mDocument.prevCluster(at), at); + } + break; + case C::DeleteToLineEnd: + // On to the line's end, or the break after it from there. + if (at != mDocument.lineEnd(at.line)) + { + return ALTextRange(at, mDocument.lineEnd(at.line)); + } + if (at != mDocument.end()) + { + return ALTextRange(at, mDocument.nextCluster(at)); + } + break; + default: + break; + } + return std::nullopt; +} + +void ALTextView::typeText(std::string_view text) +{ + if (mReadOnly || text.empty()) + { + return; + } + if (hasOtherSelections()) + { + typeAtEach(text, 0); + } + else + { + insertText(text); + } +} + +void ALTextView::typeAtEach(std::string_view text, llwchar typed) +{ + const std::string put(text); + editEach([&put](size_t, const ALTextRange& selection) { + const ALTextRange over = selection.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, put }); + one.caret = ALTextEditing::endOf(over.begin, put); + return std::optional(std::move(one)); + }); + if (typed) + { + outdentEach(typed); + } +} + +void ALTextView::outdentEach(llwchar typed, const std::function& at) +{ + // A word so brought out is not kept to be put back, as one caret's is. + mAutoOutdent = ALTextIndent::AutoOutdent(); + const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); + editEach([&](size_t i, const ALTextRange& selection) -> std::optional { + if (!selection.empty() || (at && !at(i))) + { + return std::nullopt; + } + const ALTextIndent::Outdent outdent = ALTextIndent::outdentAsTyped(mDocument, selection.begin, selection.end, typed, grammar, openerOf(), + ALTextIndent::AutoOutdent(), editingOptions()); + if (!outdent.replacement) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back(*outdent.replacement); + one.caret = ALTextEditing::placedThrough(one.replacements, selection.end); + return one; + }); +} + +std::optional ALTextView::performAtEach(ALEditorCommand command) +{ + typedef ALEditorCommand C; + if (isMotion(command)) + { + return moveEach(command); + } + switch (command) + { + case C::DeleteLeft: + case C::DeleteRight: + case C::DeleteWordLeft: + case C::DeleteWordRight: + case C::DeleteToLineStart: + case C::DeleteToLineEnd: + return deleteEach(command); + case C::NewLine: + newLineEach(); + return true; + case C::Indent: + // A selection, however small, indents its lines; a caret puts a + // tab in -- but for one on a line a selection indents, which + // goes in with the line rather than putting a tab before what + // the selection holds. + editGroups([this](const std::vector& all) { + std::vector> indented; + for (const ALTextRange& one : all) + { + if (!one.empty()) + { + indented.push_back(ALTextEditing::selectedLines(one)); + } + } + const auto onIndented = [&indented](S32 line) { + return std::ranges::any_of(indented, [line](const std::pair& lines) { return line >= lines.first && line <= lines.second; }); + }; + std::vector selected; + std::vector at; + std::vector taken(all.size(), false); + for (size_t i = 0; i < all.size(); ++i) + { + if (!all[i].empty() || onIndented(all[i].end.line)) + { + selected.push_back(all[i]); + at.push_back(i); + taken[i] = true; + } + } + std::vector groups = ALTextIndent::indentLines(mDocument, selected, true, editingOptions()); + for (ALTextEditing::Group& group : groups) + { + for (auto& placed : group.placed) + { + placed.first = at[placed.first]; + } + } + for (size_t i = 0; i < all.size(); ++i) + { + if (!taken[i]) + { + const std::string tab = tabText(all[i].end); + const ALTextPos end = ALTextEditing::endOf(all[i].end, tab); + ALTextEditing::Group group; + group.replacements.push_back({ all[i], tab }); + group.placed.emplace_back(i, ALTextRange(end, end)); + groups.push_back(std::move(group)); + } + } + return groups; + }); + return true; + case C::Unindent: + editGroups([this](const std::vector& all) { return ALTextIndent::indentLines(mDocument, all, false, editingOptions()); }); + return true; + case C::DuplicateLine: + editGroups([this](const std::vector& all) { return ALTextEditing::duplicateLines(mDocument, all); }); + return true; + case C::MoveLineUp: + case C::MoveLineDown: + { + const S32 direction = command == C::MoveLineUp ? -1 : 1; + editGroups([this, direction](const std::vector& all) { return ALTextEditing::moveLines(mDocument, all, direction); }); + return true; + } + case C::DeleteLine: + editGroups([this](const std::vector& all) { return ALTextEditing::deleteLines(mDocument, all); }); + return true; + case C::JoinLines: + return editGroups([this](const std::vector& all) { return ALTextEditing::joinLines(mDocument, all); }); + case C::InsertLineAbove: + // A line above each line a caret is on, as far in as that line, + // however many carets are on it. + editGroups([this](const std::vector& all) { + std::vector> lines; + for (size_t i = 0; i < all.size(); ++i) + { + lines.emplace_back(all[i].end.line, i); + } + std::sort(lines.begin(), lines.end()); + std::vector groups; + S32 indent = 0; + for (size_t k = 0; k < lines.size(); ++k) + { + const S32 line = lines[k].first; + if (k == 0 || line != lines[k - 1].first) + { + const std::string blanks = ALTextIndent::leadingBlanks(mDocument, line); + const ALTextPos start = mDocument.lineStart(line); + indent = static_cast(blanks.size()); + groups.emplace_back(); + groups.back().replacements.push_back({ ALTextRange(start, start), blanks + "\n" }); + } + const ALTextPos caret(line, indent); + groups.back().placed.emplace_back(lines[k].second, ALTextRange(caret, caret)); + } + return groups; + }); + scrollToCaret(); + return true; + case C::InsertLineBelow: + { + // As Return at each line's end would make it. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + for (ALTextRange& one : all) + { + const ALTextPos end = mDocument.lineEnd(one.end.line); + one = ALTextRange(end, end); + } + placeSelections(all, main); + newLineEach(); + scrollToCaret(); + return true; + } + case C::SelectLine: + { + // Each selection's lines whole, with their breaks. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + for (ALTextRange& one : all) + { + const ALTextRange range = one.normalised(); + const S32 past = range.end.line + 1; + one = ALTextRange(mDocument.lineStart(range.begin.line), + past < mDocument.lineCount() ? mDocument.lineStart(past) : mDocument.lineEnd(mDocument.lineCount() - 1)); + } + placeSelections(all, main); + mDesiredX = -1.f; + scrollToCaret(); + return true; + } + // Done as they always are: those that add carets or grow a column, + // the clipboard's, the comment's and completion know of the others; + // select all, undo and redo put them as they put the main one; + // folding and the find bar shown leave them be. + case C::None: + case C::COUNT: + case C::AddCaretAbove: + case C::AddCaretBelow: + case C::ColumnSelectLeft: + case C::ColumnSelectRight: + case C::ColumnSelectUp: + case C::ColumnSelectDown: + case C::SelectNextOccurrence: + case C::ChangeAllOccurrences: + case C::Complete: + case C::ToggleComment: + case C::Cut: + case C::Copy: + case C::Paste: + case C::Delete: + case C::SelectAll: + case C::Undo: + case C::Redo: + case C::Fold: + case C::Unfold: + case C::FoldAll: + case C::UnfoldAll: + case C::Find: + case C::Replace: + return std::nullopt; + default: + // At the main one alone: the others let go. + singleSelection(); + return std::nullopt; + } } -void ALTextView::deleteRange(const ALTextRange& range) +bool ALTextView::moveEach(ALEditorCommand command) { - if (mReadOnly) - { - return; - } - if (!edit(range, std::string_view()).nothing()) + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + // The x each caret keeps between rows, where the last motion left them + // and they have not moved since; else each from where it is. + const bool vertical = isVertical(command); + std::vector xs = vertical ? desiredXs(all) : std::vector(all.size(), -1.f); + std::vector placed(all.size()); + for (size_t i = 0; i < all.size(); ++i) { - afterEdit(); + placed[i] = moved(command, all[i], xs[i]); } -} - -// --- line commands and indentation ---------------------------------------------- - -ALTextPos ALTextView::wordStep(const ALTextPos& from, bool forward, bool parts) const -{ - // Code by its names and marks, as a double-click takes a name; prose, - // and a text with no grammar, by its words. - const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); - if (parts || (grammar && !grammar->prose())) + placeSelections(placed, main); + mDesiredX = xs[main]; + mEachDesired = EachDesired(); + if (vertical) { - return forward ? mDocument.nextCodeWord(from, parts) : mDocument.prevCodeWord(from, parts); + std::vector now = selectionsInOrder(); + // Kept only while each is the one it was: none merged. + if (now.size() == all.size()) + { + mEachDesired.selections = std::move(now); + mEachDesired.xs = std::move(xs); + mEachDesired.under = laidOut(); + } } - return forward ? mDocument.nextWord(from) : mDocument.prevWord(from); -} - -ALTextIndent::Options ALTextView::editingOptions() const -{ - ALTextIndent::Options options; - options.tabWidth = mTabWidth; - options.softTabs = mSoftTabs; - return options; + scrollToCaret(); + return true; } -ALTextIndent::opener_t ALTextView::openerOf() +bool ALTextView::deleteEach(ALEditorCommand command) { - return [this](const ALTextPos& closer, ALTextPos& opener) { return mFeatures && mFeatures->closerOpenedAt(closer, opener); }; + // A key that erases, as one: a run of them one step to undo. + mUndo.beginTyping(selection(), true); + editEach([this, command](size_t, const ALTextRange& selection) -> std::optional { + const std::optional range = erasedBy(command, selection); + if (!range || range->empty()) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back({ range->normalised(), std::string() }); + one.caret = range->normalised().begin; + return one; + }); + mUndo.endTyping(); + return true; } -void ALTextView::apply(const ALTextEditing::Change& change) +bool ALTextView::deleteSelectedEach() { - // One edit however many lines it touches: an indent of every line is - // one notification, not one a line. - std::vector> edits; - edits.reserve(change.replacements.size()); - for (const ALTextEditing::Replacement& one : change.replacements) - { - edits.emplace_back(one.range, one.text); - } - // Not made at all, the caret left where it was, where the text has - // no room for it. - if (!fits(edits)) - { - return; - } mUndo.beginGroup(); - editMany(std::move(edits), change.caret); + const bool done = editEach([](size_t, const ALTextRange& selection) -> std::optional { + if (selection.empty()) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back({ selection.normalised(), std::string() }); + one.caret = selection.normalised().begin; + return one; + }); mUndo.endGroup(); - if (change.selects) - { - placeSelection(change.anchor, change.caret); - } - else - { - placeCaret(change.caret, false); - } - afterEdit(); + return done; } -std::string ALTextView::tabText(const ALTextPos& at) const +void ALTextView::newLineEach() { - return ALTextIndent::tabText(mDocument, at, editingOptions()); + const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); + // A key typed, whatever it takes: one with the typing around it. + mUndo.beginTyping(selection()); + // First what the Return finishes, brought out at each caret. + editEach([&](size_t, const ALTextRange& selection) -> std::optional { + const std::optional closing = + ALTextIndent::closingBeforeReturn(mDocument, selection.begin, selection.end, grammar, openerOf(), editingOptions()); + if (!closing) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back(*closing); + one.caret = ALTextEditing::placedThrough(one.replacements, selection.end); + return one; + }); + // Then each line split there. + editEach([&](size_t, const ALTextRange& selection) { + const bool in_comment = selection.empty() && commentBefore(selection.end); + const ALTextIndent::Split split = ALTextIndent::splitLine(mDocument, selection, grammar, editingOptions(), in_comment); + const ALTextRange over = split.range.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, split.text }); + one.caret = split.caret ? *split.caret : ALTextEditing::endOf(over.begin, split.text); + return std::optional(std::move(one)); + }); + mUndo.endTyping(); } void ALTextView::newLine() @@ -2211,19 +3154,8 @@ void ALTextView::newLine() } // In a comment, as the token before the caret says: one that goes on // begins the new line as its lines do. - bool in_comment = false; - if (grammar && mCaret.column > 0 && !hasSelection()) - { - for (const ALSyntaxToken& token : mHighlighter.tokens(mCaret.line)) - { - if (token.begin < mCaret.column && mCaret.column <= token.end) - { - in_comment = token.kind == ALSyntaxKind::Comment || token.kind == ALSyntaxKind::DocComment; - break; - } - } - } - const ALTextIndent::Split split = ALTextIndent::splitLine(mDocument, selection(), grammar, editingOptions(), in_comment); + const bool in_comment = !hasSelection() && commentBefore(mCaret); + const ALTextIndent::Split split = ALTextIndent::splitLine(mDocument, selection(), grammar, editingOptions(), in_comment); setSelection(split.range); insertText(split.text); if (split.caret) @@ -2251,6 +3183,13 @@ bool ALTextView::toggleComment() { return false; } + if (hasOtherSelections()) + { + // In or out the same way at every selection. + const std::string token = mHighlighter.grammar()->lineComment(); + editGroups([this, &token](const std::vector& all) { return ALTextEditing::toggleComment(mDocument, all, token); }); + return true; + } if (const std::optional change = ALTextEditing::toggleComment(mDocument, mAnchor, mCaret, mHighlighter.grammar()->lineComment())) { apply(*change); @@ -2261,92 +3200,57 @@ bool ALTextView::toggleComment() bool ALTextView::perform(ALEditorCommand command) { typedef ALEditorCommand C; - const bool extend = command >= C::SelectLeft && command <= C::SelectPageDown; - auto move = [&](const ALTextPos& to) { - placeCaret(to, extend); - mDesiredX = -1.f; - scrollToCaret(); - return true; - }; - // An arrow with a selection and no shift collapses it to that end. - auto collapse = [&](bool to_begin) { - if (!hasSelection() || extend) - { - return false; - } - const ALTextRange range = selection().normalised(); - return move(to_begin ? range.begin : range.end); - }; if (mReadOnly && editsText(command)) { return false; } + // At every selection, where there are several. + if (hasOtherSelections()) + { + if (const std::optional done = performAtEach(command)) + { + return *done; + } + } switch (command) { case C::None: case C::COUNT: return false; case C::MoveLeft: - case C::SelectLeft: - return collapse(true) || move(mDocument.prevCluster(mCaret)); case C::MoveRight: - case C::SelectRight: - return collapse(false) || move(mDocument.nextCluster(mCaret)); case C::MoveUp: - case C::SelectUp: - moveVertically(-1, extend); - return true; case C::MoveDown: - case C::SelectDown: - moveVertically(1, extend); - return true; case C::MoveWordLeft: - case C::SelectWordLeft: - return move(wordStep(mCaret, false, false)); case C::MoveWordRight: - case C::SelectWordRight: - return move(wordStep(mCaret, true, false)); - case C::MoveSubwordLeft: - case C::SelectSubwordLeft: - case C::MoveSubwordRight: - case C::SelectSubwordRight: - { - const bool forward = command == C::MoveSubwordRight || command == C::SelectSubwordRight; - placeCaret(wordStep(mCaret, forward, true), command == C::SelectSubwordLeft || command == C::SelectSubwordRight); - mDesiredX = -1.f; - scrollToCaret(); - return true; - } case C::MoveLineStart: - case C::SelectLineStart: - { - // To the first thing on the line, or to the line's start from - // there. - const std::string& line = mDocument.line(mCaret.line); - S32 indent = 0; - while (indent < static_cast(line.size()) && (line[indent] == ' ' || line[indent] == '\t')) - { - ++indent; - } - return move(ALTextPos(mCaret.line, mCaret.column == indent ? 0 : indent)); - } case C::MoveLineEnd: - case C::SelectLineEnd: - return move(mDocument.lineEnd(mCaret.line)); case C::MoveDocStart: - case C::SelectDocStart: - return move(mDocument.start()); case C::MoveDocEnd: - case C::SelectDocEnd: - return move(mDocument.end()); case C::MovePageUp: - case C::SelectPageUp: - moveVertically(-rowsPerPage(), extend); - return true; case C::MovePageDown: + case C::SelectLeft: + case C::SelectRight: + case C::SelectUp: + case C::SelectDown: + case C::SelectWordLeft: + case C::SelectWordRight: + case C::SelectLineStart: + case C::SelectLineEnd: + case C::SelectDocStart: + case C::SelectDocEnd: + case C::SelectPageUp: case C::SelectPageDown: - moveVertically(rowsPerPage(), extend); + case C::MoveSubwordLeft: + case C::MoveSubwordRight: + case C::SelectSubwordLeft: + case C::SelectSubwordRight: + { + const ALTextRange to = moved(command, selection(), mDesiredX); + placeSelection(to.begin, to.end); + scrollToCaret(); return true; + } case C::SelectAll: selectAll(); return true; @@ -2530,6 +3434,17 @@ bool ALTextView::perform(ALEditorCommand command) case C::NextMisspelling: case C::PreviousMisspelling: return goToMisspelling(command == C::NextMisspelling); + case C::AddCaretAbove: + case C::AddCaretBelow: + return !mReadOnly && addCarets(command == C::AddCaretAbove ? -1 : 1); + case C::ColumnSelectLeft: + return !mReadOnly && growColumn(-1, 0); + case C::ColumnSelectRight: + return !mReadOnly && growColumn(1, 0); + case C::ColumnSelectUp: + return !mReadOnly && growColumn(0, -1); + case C::ColumnSelectDown: + return !mReadOnly && growColumn(0, 1); case C::Find: showFind(false); return true; @@ -2587,6 +3502,14 @@ bool ALTextView::canPerform(ALEditorCommand command) const case C::SelectNextOccurrence: case C::ChangeAllOccurrences: return mFeatures && mFeatures->canPerformFeature(command); + case C::AddCaretAbove: + case C::AddCaretBelow: + case C::ColumnSelectLeft: + case C::ColumnSelectRight: + case C::ColumnSelectUp: + case C::ColumnSelectDown: + // A read-only text shows no caret to add to or grow from. + return !mReadOnly; case C::PreviousChange: return mChangeAt > 0; case C::NextChange: @@ -2678,8 +3601,10 @@ void ALTextView::undo() { resetPreedit(); } - if (std::optional selected = mUndo.undo()) + std::vector others; + if (std::optional selected = mUndo.undo(&others)) { + mCarets.assign(clamped(std::move(others))); placeSelection(selected->begin, selected->end); afterEdit(); } @@ -2695,8 +3620,10 @@ void ALTextView::redo() { resetPreedit(); } - if (std::optional selected = mUndo.redo()) + std::vector others; + if (std::optional selected = mUndo.redo(&others)) { + mCarets.assign(clamped(std::move(others))); placeSelection(selected->begin, selected->end); afterEdit(); } @@ -2712,6 +3639,20 @@ void ALTextView::cut() return; } copy(); + if (hasOtherSelections()) + { + // What each selected; or where none selected anything, each caret's + // line whole. + if (anySelected()) + { + deleteSelectedEach(); + } + else + { + editGroups([this](const std::vector& all) { return ALTextEditing::deleteLines(mDocument, all); }); + } + return; + } if (!hasSelection()) { // The whole line, the caret on the one that takes its place. @@ -2723,6 +3664,34 @@ void ALTextView::cut() void ALTextView::copy() { + if (hasOtherSelections()) + { + // Each selection's text, one to a line in the order they begin: or + // a caret's whole line where lines are clipped and none selects + // anything -- what a cut takes -- else nothing of it. + std::string text; + bool any = false; + const bool selected = anySelected(); + for (const ALTextRange& one : selectionsInOrder()) + { + if (one.empty() && (!mClipsLines || selected)) + { + continue; + } + if (any) + { + text += '\n'; + } + text += one.empty() ? mDocument.line(one.end.line) : mDocument.text(one); + any = true; + } + if (any) + { + sClippedLine.clear(); + LLClipboard::instance().copyToClipboard(text, 0, static_cast(text.size())); + } + return; + } if (!hasSelection()) { if (!mClipsLines) @@ -2755,6 +3724,43 @@ void ALTextView::paste() { return; } + if (hasOtherSelections()) + { + // Its lines, as the text will have them. + LLStringUtil::replaceString(text, "\r\n", "\n"); + LLStringUtil::replaceChar(text, '\r', '\n'); + std::vector lines; + for (size_t from = 0;;) + { + const size_t end = text.find('\n', from); + lines.push_back(text.substr(from, end == std::string::npos ? std::string::npos : end - from)); + if (end == std::string::npos) + { + break; + } + from = end + 1; + } + // One line to each selection where there is a line for each -- a + // break after the last not counted -- and the whole to each + // otherwise. + const size_t count = mCarets.size() + 1; + if (lines.size() == count + 1 && lines.back().empty()) + { + lines.pop_back(); + } + const bool one_each = lines.size() == count; + mUndo.beginGroup(); + editEach([&](size_t index, const ALTextRange& selection) { + const std::string& put = one_each ? lines[index] : text; + const ALTextRange over = selection.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, put }); + one.caret = ALTextEditing::endOf(over.begin, put); + return std::optional(std::move(one)); + }); + mUndo.endGroup(); + return; + } // A line copied whole goes in as a line, above the caret's, the caret // where it was in its own. if (mClipsLines && !hasSelection() && !sClippedLine.empty() && text == sClippedLine) @@ -2790,10 +3796,16 @@ void ALTextView::paste() void ALTextView::doDelete() { - if (canDoDelete()) + if (!canDoDelete()) { - deleteRange(selection()); + return; } + if (hasOtherSelections()) + { + deleteSelectedEach(); + return; + } + deleteRange(selection()); } void ALTextView::selectAll() @@ -3190,6 +4202,7 @@ void ALTextView::showFind(bool with_replace) mFindBar->onPrevious([this]() { findNext(false); }); mFindBar->onReplace([this]() { replaceMatch(); }); mFindBar->onReplaceAll([this]() { replaceAllMatches(); }); + mFindBar->onSelectAll([this]() { selectAllMatches(); }); mFindBar->onClose([this]() { hideFind(); }); } // Seeded with what is selected, when that is a line's worth or less, @@ -3352,6 +4365,9 @@ bool ALTextView::findNext(bool forward) // one at or after it. const S32 wrapped = forward ? (match.begin < from ? 1 : 0) : (match.begin < from ? 0 : -1); mFind.setCurrent(index); + // The match alone, the other carets let go, from the bar's buttons as + // from the keys. + singleSelection(); setSelection(match); mFindBar->setCount(mFind.current(), static_cast(mFind.count()), mFind.error(), mFind.capped(), wrapped); return true; @@ -3869,9 +4885,6 @@ void ALTextView::drawRows(const LLRect& text) const F32 blink = mBlink.getElapsedTimeF32(); const bool caret_on = show_caret && (!mCaretBlink || blink < BLINK_DELAY || (static_cast(blink * 2.f) & 1)); const ALTextRange sel = selection().normalised(); - const bool has_sel = !sel.empty(); - S32 caret_row = 0; - const F32 caret_x = mLayout.xOf(mCaret.line, mCaret.column, &caret_row); const F32 space = mLayout.xOf(0, 0) + 6.f; // what a selected line end is drawn as const F32 left = static_cast(text.mLeft) - mScrollX; @@ -3913,8 +4926,9 @@ void ALTextView::drawRows(const LLRect& text) } } - // Behind the text: the selection and what the find bar found. - const std::vector& matches = mFind.matches(); + // Behind the text: the selections and what the find bar found. + const std::vector& matches = mFind.matches(); + const LLColor4 sel_color = selectionDrawColor() % alpha; gGL.getTextureSlot(0)->unbind(); gGL.begin(LLRender::TRIANGLES); for (const RowSeen& seen : mRowsSeen) @@ -3923,10 +4937,16 @@ void ALTextView::drawRows(const LLRect& text) const ALTextLayout::Line& laid = mLayout.line(line); const ALTextLayout::Row& row = laid.rows[static_cast(seen.row)]; const S32 length = mDocument.lineLength(line); - if (has_sel && sel.begin.line <= line && line <= sel.end.line) - { - const S32 sel_begin = sel.begin.line < line ? 0 : sel.begin.column; - const S32 sel_end = sel.end.line > line ? length + 1 : sel.end.column; + // A selection's band on the row, in order, from its start or the + // row's to its end or the row's; past the line's end where it goes + // on to the next. + const auto band = [&](const ALTextRange& range) { + if (range.empty() || line < range.begin.line || range.end.line < line) + { + return; + } + const S32 sel_begin = range.begin.line < line ? 0 : range.begin.column; + const S32 sel_end = range.end.line > line ? length + 1 : range.end.column; const S32 lo = llmax(sel_begin, row.begin); const bool last_row = (static_cast(seen.row) + 1 == laid.rows.size()); const S32 hi = llmin(sel_end, last_row ? length + 1 : row.end); @@ -3935,8 +4955,13 @@ void ALTextView::drawRows(const LLRect& text) const F32 x0 = mLayout.xOf(line, lo); const F32 x1 = hi > length ? row.width + space : (hi >= row.end && !last_row ? row.width : mLayout.xOf(line, hi)); gl_rect_2d_in_batch(static_cast(left + x0), seen.rowScreenTop, static_cast(left + x1), seen.rowScreenTop - row.height, - selectionDrawColor() % alpha); + sel_color); } + }; + band(sel); + for (auto [it, end] = mCarets.onLine(line); it != end; ++it) + { + band(it->normalised()); } if (!matches.empty()) { @@ -4000,39 +5025,69 @@ void ALTextView::drawRows(const LLRect& text) drawLayers(line, laid, static_cast(r), text, row_screen_top, left, alpha); drawRowExtras(line, static_cast(r), text, screen_top, left, alpha); - // The caret. - if (caret_on && line == mCaret.line && static_cast(r) == caret_row) + // The carets on the row, every one as the main one is. + if (caret_on) { - const S32 x = static_cast(left + caret_x); - const bool modal = mModal && !mModal->inserting(); - if (modal || mCaretStyle != CaretStyle::Line) - { - // A block over the cluster the caret is on, as a modal - // editor's is; a space's width past the line's end. - const ALTextPos next = mDocument.nextCluster(mCaret); - const F32 cell = llmax(4.f, mLayout.columnWidth()); - F32 right = next.line == mCaret.line && next != mCaret ? mLayout.xOf(mCaret.line, next.column) : caret_x + cell; - if (right <= caret_x) + const auto caret = [&](const ALTextPos& at) { + if (at.line != line) + { + return; + } + S32 at_row = 0; + const F32 at_x = mLayout.xOf(at.line, at.column, &at_row); + if (at_row != static_cast(r)) { - right = caret_x + cell; + return; } - if (!modal && mCaretStyle == CaretStyle::Underline) + const S32 x = static_cast(left + at_x); + const bool modal = mModal && !mModal->inserting(); + if (modal || mCaretStyle != CaretStyle::Line) { - // A bar under the cluster, as thick as the line caret is wide. - gl_rect_2d(x, row_screen_top - row.height + CARET_WIDTH, static_cast(left + right), row_screen_top - row.height, - mCursorColor.get() % alpha); + // A block over the cluster the caret is on, as a modal + // editor's is; a space's width past the line's end. + const ALTextPos next = mDocument.nextCluster(at); + const F32 cell = llmax(4.f, mLayout.columnWidth()); + F32 right = next.line == at.line && next != at ? mLayout.xOf(at.line, next.column) : at_x + cell; + if (right <= at_x) + { + right = at_x + cell; + } + if (!modal && mCaretStyle == CaretStyle::Underline) + { + // A bar under the cluster, as thick as the line caret is wide. + mCaretBoxes.push_back({ LLRect(x, row_screen_top - row.height + CARET_WIDTH, static_cast(left + right), row_screen_top - row.height), + mCursorColor.get() % alpha }); + } + else + { + mCaretBoxes.push_back({ LLRect(x, row_screen_top, static_cast(left + right), row_screen_top - row.height), + mCursorColor.get() % (0.55f * alpha) }); + } } else { - gl_rect_2d(x, row_screen_top, static_cast(left + right), row_screen_top - row.height, mCursorColor.get() % (0.55f * alpha)); + mCaretBoxes.push_back({ LLRect(x, row_screen_top, x + CARET_WIDTH, row_screen_top - row.height), mCursorColor.get() % alpha }); } - } - else + }; + caret(mCaret); + for (auto [it, end] = mCarets.onLine(line); it != end; ++it) { - gl_rect_2d(x, row_screen_top, x + CARET_WIDTH, row_screen_top - row.height, mCursorColor.get() % alpha); + caret(it->end); } } } + // The carets together, over every row's layers. + if (!mCaretBoxes.empty()) + { + gGL.getTextureSlot(0)->unbind(); + gGL.begin(LLRender::TRIANGLES); + for (const CaretBox& box : mCaretBoxes) + { + gl_rect_2d_in_batch(box.rect.mLeft, box.rect.mTop, box.rect.mRight, box.rect.mBottom, box.color); + } + gGL.end(); + mCaretBoxes.clear(); + } mQueueSquiggles = false; drawSquiggles(mSquiggles.data(), mSquiggles.size()); mSquiggles.clear(); @@ -4054,6 +5109,20 @@ void ALTextView::dragSelectTo(S32 x, S32 y) const S32 rows = 1 + past / row_h; setScrollY(mScrollY + (y > text.mTop ? -rows : rows) * row_h); } + if (mColumnDragging) + { + const ColumnCorner corner = cornerAtLocal(x, y); + if (corner == mColumn.to) + { + return; + } + selectColumn(mColumn.from, corner); + if (mModal) + { + mModal->mouseChanged(*this); + } + return; + } const ALTextPos at = posAtLocal(x, y, true); const ALTextPos character = posAtLocal(x, y, false); if (at == mDragAt && character == mDragToChar) @@ -4358,6 +5427,11 @@ bool ALTextView::handleKeyHere(KEY key, MASK mask) // the keyboard away, out into the world, where the arrows walk. if (key == KEY_ESCAPE && mask == MASK_NONE) { + // Back to one caret first, its selection kept; then that let go. + if (singleSelection()) + { + return true; + } const bool selected = hasSelection(); if (selected) { @@ -4404,8 +5478,15 @@ bool ALTextView::handleUnicodeCharHere(llwchar uni_char) return false; } mUndo.beginTyping(selection()); - insertText(utf8str_from_cp(uni_char)); - outdentAsTyped(uni_char); + if (hasOtherSelections()) + { + typeAtEach(utf8str_from_cp(uni_char), uni_char); + } + else + { + insertText(utf8str_from_cp(uni_char)); + outdentAsTyped(uni_char); + } mUndo.endTyping(); if (LLWindow* window = getWindow()) { @@ -4454,6 +5535,38 @@ bool ALTextView::handleMouseDown(S32 x, S32 y, MASK mask) return true; } mTripleClick.stop(); + // Alt-click -- Option on a Mac -- a caret more, or the one there taken + // away; any other click one caret again. + if (mask == MASK_ALT && !mReadOnly) + { + toggleCaret(posAtLocal(x, y, true)); + mDesiredX = -1.f; + if (mModal) + { + mModal->mouseChanged(*this); + } + return true; + } + // Shift-Alt -- Shift-Option on a Mac -- a column from the anchor to the + // press, as Shift-click takes a selection from it, grown as the mouse + // is dragged. + if (mask == (MASK_SHIFT | MASK_ALT) && !mReadOnly) + { + mPressedLink = -1; + mPressedAtom = -1; + selectColumn(columnCorners().first, cornerAtLocal(x, y)); + mColumnDragging = true; + mSelecting = true; + mDragX = x; + mDragY = y; + gFocusMgr.setMouseCapture(this); + if (mModal) + { + mModal->mouseChanged(*this); + } + return true; + } + singleSelection(); // A link or an atom under the press is followed on the release, if // the release is on it too. mPressedLink = -1; @@ -4505,7 +5618,8 @@ bool ALTextView::handleMiddleMouseDown(S32 x, S32 y, MASK mask) } // Where the press is, leaving what is selected in place rather than // replacing it, as X11 does: the selection goes, the text it had stays - // the primary one. + // the primary one; and one caret. + singleSelection(); placeCaret(posAtLocal(x, y, true), false); mDesiredX = -1.f; mUndo.beginGroup(); @@ -4631,7 +5745,8 @@ bool ALTextView::handleMouseUp(S32 x, S32 y, MASK mask) } if (mSelecting) { - mSelecting = false; + mSelecting = false; + mColumnDragging = false; gFocusMgr.setMouseCapture(nullptr); if (mPrimaryStale) { @@ -4685,8 +5800,9 @@ bool ALTextView::handleDoubleClick(S32 x, S32 y, MASK mask) setFocus(true); const ALTextRange word = mDocument.wordAt(posAtLocal(x, y, false)); placeSelection(word.begin, word.end); - mDesiredX = -1.f; - mSelecting = false; + mDesiredX = -1.f; + mSelecting = false; + mColumnDragging = false; armTripleClick(); if (mModal) { @@ -4747,8 +5863,9 @@ bool ALTextView::handleScrollHWheel(S32 x, S32 y, LLScrollDelta delta) void ALTextView::onMouseCaptureLost() { - mSelecting = false; - mDraggingBar = false; + mSelecting = false; + mColumnDragging = false; + mDraggingBar = false; } bool ALTextView::handleToolTip(S32 x, S32 y, MASK mask) diff --git a/indra/llui/altextview.h b/indra/llui/altextview.h index 1b8ebd54ae..d876de90fa 100644 --- a/indra/llui/altextview.h +++ b/indra/llui/altextview.h @@ -27,6 +27,7 @@ #include "alanchoredranges.h" #include "alkeymap.h" #include "alsyntaxhighlighter.h" +#include "altextcarets.h" #include "altextdocument.h" #include "altextediting.h" #include "altextfind.h" @@ -58,8 +59,9 @@ class ALVimHost; class LLContextMenu; // A view of a document: the lines laid out and drawn, only the ones in -// sight, a caret and a selection in them, a keymap that turns keys into -// commands, and a grammar that colours what it shows. Text is drawn from +// sight, a caret and a selection in them -- and others beside those, +// where something made them -- a keymap that turns keys into commands, +// and a grammar that colours what it shows. Text is drawn from // glyph runs the layout shaped once, so a frame costs the rows on screen // and nothing that is not. The document, the undo journal, the highlighter // and the layout are its own, and reachable, for whatever is built over it @@ -345,10 +347,53 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec // underscores back from the caret. Empty at anything else. std::string wordBeforeCaret() const; + // --- several carets --------------------------------------------------------- + + // The selections besides the main one, each anchor to caret, in the + // order they begin (ALTextCarets). Drawn as the main one is, slid along + // by every edit, and kept by each step to undo; merged with each other + // and with the main one where they meet. Keys act at every one of them + // -- typing, deleting, Return and Tab, the commands over lines, the + // clipboard, and the caret's motions -- each as at the main one, as one + // edit and one step to undo; a command that can act only at one acts at + // the main one and lets the others go. + const std::vector& otherSelections() const { return mCarets.selections(); } + bool hasOtherSelections() const { return !mCarets.empty(); } + // One more beside the main one, which stays main. + void addSelection(const ALTextRange& range); + // The main selection and the others at once. + void setSelections(const ALTextRange& main, std::vector others); + // The main selection alone again; whether there were others. + bool singleSelection(); + // A caret put at a place as the main one, the others kept -- or, where + // a caret or a selection is there, that one taken away, unless it is + // the last: what Alt-click does. + void toggleCaret(const ALTextPos& pos); + // A caret more on the row above each caret, or below, each keeping + // its column as a motion between rows does; the furthest the main + // one. False where every caret is on the first or the last row. + bool addCarets(S32 direction); + // A column selection grown at its moving corner by so many columns + // sideways and rows down, or up below zero; begun from the main + // selection's anchor and caret where the selections are not a column's. + // Each row between the corners has a selection from the x of the corner + // that stays to the x of the one that moves, so that a tab or a wide + // character counts as it is drawn and a row too short has a caret at + // its end; the one on the moving corner's row is the main one. False + // where it cannot go further that way. + bool growColumn(S32 columns, S32 rows); + // Every match the find bar has selected, the current one the main + // one, and the keyboard to the text; how many. + S32 selectAllMatches(); + // --- editing, through the undo journal ----------------------------------- // In place of the selection, or at the caret. void insertText(std::string_view text); + // As typed: at every selection where there are several, else as + // insertText -- what a keymap that holds keys back puts in when it + // lets them go. + void typeText(std::string_view text); void deleteRange(const ALTextRange& range); // Several ranges of the text as it stands, each replaced by its // string, as one step to undo: what a rename is. The ranges must not @@ -376,7 +421,7 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec bool typingText() const { return !mModal || mModal->inserting(); } // The caret put at a place, or a stretch selected, and brought into - // view: where a list of places sends it. + // view, one caret: where a list of places sends it. void goTo(const ALTextPos& pos); void goTo(const ALTextRange& range); @@ -695,13 +740,13 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec void redo() override; bool canRedo() const override { return !mReadOnly && mUndo.canRedo(); } void cut() override; - bool canCut() const override { return !mReadOnly && (hasSelection() || mClipsLines); } + bool canCut() const override { return !mReadOnly && (anySelected() || mClipsLines); } void copy() override; - bool canCopy() const override { return hasSelection() || mClipsLines; } + bool canCopy() const override { return anySelected() || mClipsLines; } void paste() override; bool canPaste() const override; void doDelete() override; - bool canDoDelete() const override { return !mReadOnly && hasSelection(); } + bool canDoDelete() const override { return !mReadOnly && anySelected(); } void selectAll() override; bool canSelectAll() const override { return !mDocument.empty(); } void deselect() override; @@ -896,6 +941,42 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec // as though it were not. ALTextRange withoutComposition(const ALTextRange& range) const; + // --- at every selection ------------------------------------------------------- + + // Every selection, the main one among them, in the order they begin; + // and where among them the main one is. + std::vector selectionsInOrder(size_t* main = nullptr) const; + // Whether any selection, the main one or another, has something in it. + bool anySelected() const; + // Selections put at once, the one at `main` the main one: where a + // command at every selection leaves them. + void placeSelections(const std::vector& selections, size_t main); + // A command at every selection (selectionsInOrder), in the groups it was + // worked out in over the text as it stands (ALTextEditing::combine): + // made as one edit -- one notification, and one step to undo with the + // journal's scope around it -- and each selection put where its group + // leaves it, any no group places slid along with the text. False where + // the text did not change: nothing would, or it has no room. + bool applyGroups(const std::vector& selections, size_t main, std::vector groups); + // The same, worked out at each selection on its own: what `change` + // makes of the one at an index, or nothing for one it leaves as it is. + typedef std::function(size_t index, const ALTextRange& selection)> each_change_t; + bool editEach(const each_change_t& change); + // Groups worked out over every selection at once, made as one step to + // undo of its own: the commands over whole lines. + typedef std::function(const std::vector& selections)> groups_t; + bool editGroups(const groups_t& groups); + // What a delete command takes at a selection: the selection, or from a + // caret what the command reaches; nothing where it reaches nothing. + std::optional erasedBy(ALEditorCommand command, const ALTextRange& selection) const; + // Typed at every selection: the text in place of each, and then, where + // it was a character that finishes what closes a block, that line + // brought out at each (outdentAsTyped) -- one key typed. + void typeAtEach(std::string_view text, llwchar typed); + // What a character just typed finishes brought out at each caret, or + // at those `at` says by their place in order (outdentAsTyped). + void outdentEach(llwchar typed, const std::function& at = nullptr); + // --- LLPreeditor --------------------------------------------------------- void resetPreedit() override; @@ -909,7 +990,79 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec const std::string& getPreeditStringUtf8() const override; private: - void moveVertically(S32 rows, bool extend); + // A motion of the caret at a selection: where the caret goes, the + // anchor kept where the motion extends, and an arrow with no shift + // collapsing what is selected to that end; the x a caret keeps between + // rows in `desired_x`, which every other motion lets go of. + ALTextRange moved(ALEditorCommand command, const ALTextRange& selection, F32& desired_x); + // Where a caret goes so many rows down, or up below zero, keeping to an + // x -- taken from where it is, where that is negative -- or to the + // text's start or end past its first or last row, letting the x go. + ALTextPos rowsFrom(const ALTextPos& from, S32 rows, F32& desired_x); + // Whether the token a caret is at the end of is a comment's. + bool commentBefore(const ALTextPos& at); + // A command where there are several selections: done at every one and + // whether it was, as perform() says; or nothing for one perform() does + // as it would, having let the others go where it acts at the main one + // alone. + std::optional performAtEach(ALEditorCommand command); + bool moveEach(ALEditorCommand command); + bool deleteEach(ALEditorCommand command); + void newLineEach(); + // Each selection with something in it gone, as one step to undo. + bool deleteSelectedEach(); + // The x each caret keeps between rows, as a vertical motion of several + // left them, for as long as they are where it left them. + // An x is the layout's, in pixels: kept under the layout it was taken + // in, wrapped as wide and its characters as wide, and no other. + struct LaidOut + { + S32 wrap = -1; + F32 column = -1.f; + bool operator==(const LaidOut&) const = default; + }; + LaidOut laidOut(); + struct EachDesired + { + std::vector selections; + std::vector xs; + LaidOut under; + }; + EachDesired mEachDesired; + // The xs kept for `all`, where they are still those selections' and + // the layout's; none (-1) otherwise. + std::vector desiredXs(const std::vector& all); + // One row on from a row of a line, down or up below zero, past the + // lines folded away; false at the first or the last. + bool stepRow(S32& line, S32& row, S32 direction); + // A corner of a column selection: a row of a line, and an x from the + // row's start that may lie past its end. + struct ColumnCorner + { + S32 line = 0; + S32 row = 0; + F32 x = 0.f; + bool operator==(const ColumnCorner&) const = default; + }; + ColumnCorner cornerAt(const ALTextPos& pos); + ColumnCorner cornerAtLocal(S32 x, S32 y); + // The selections from one corner to the other (growColumn), kept as + // the column for as long as they stand as they were put. + void selectColumn(const ColumnCorner& from, const ColumnCorner& to); + // The corners the column grows from now: its own while the selections + // are the ones it put, else the main selection's anchor and caret. + std::pair columnCorners(); + struct Column + { + ColumnCorner from; + ColumnCorner to; + std::vector selections; + LaidOut under; + }; + Column mColumn; + // A drag begun with Shift-Alt, which puts a column rather than a + // selection. + bool mColumnDragging = false; // Return: the line split with the new one indented as the grammar // says, and a closing word before the caret brought out first. void newLine(); @@ -1071,6 +1224,10 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec ALTextPos mCaret; ALTextPos mAnchor; + // The selections besides that one. + ALTextCarets mCarets; + // Each into the text, as the main one is put. + std::vector clamped(std::vector selections) const; // The x the caret wants when it moves between rows, or negative. F32 mDesiredX = -1.f; S32 mScrollY = 0; @@ -1167,6 +1324,13 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec std::vector mRunColours; std::vector mRunColourAt; std::vector mSquiggles; + // A frame's carets, drawn together once the rows are. + struct CaretBox + { + LLRect rect; + LLColor4 color; + }; + std::vector mCaretBoxes; // What the band under the text shows and where its pieces go, as the // keymap had it at its generation in this font: read and measured // again only when the keymap has moved on. diff --git a/indra/llui/alvimkeymap.cpp b/indra/llui/alvimkeymap.cpp index b00e66ace3..2c6d369d0a 100644 --- a/indra/llui/alvimkeymap.cpp +++ b/indra/llui/alvimkeymap.cpp @@ -488,7 +488,8 @@ void ALVimKeymap::flushHeld(ALTextView& view) { const std::string text = std::move(mHeldTyped); mHeldTyped.clear(); - view.insertText(text); + // At every caret, as the keys it held would have gone in. + view.typeText(text); } } @@ -991,6 +992,9 @@ bool ALVimKeymap::normal(ALTextView& view, const Input& input) { view.perform(ALEditorCommand::Redo); } + // Normal mode works at one caret, whatever the step + // brought back. + view.singleSelection(); moveTo(view, view.caret()); clearPending(); return true; @@ -2396,6 +2400,9 @@ std::optional ALVimKeymap::normalKey(ALTextView& view, llwchar ch) { view.perform(ALEditorCommand::Undo); } + // Normal mode works at one caret, whatever the step brought + // back. + view.singleSelection(); moveTo(view, view.caret()); clearPending(); return true; @@ -3782,6 +3789,9 @@ void ALVimKeymap::enterInsert(ALTextView& view, S32 count, bool grouped) void ALVimKeymap::leaveInsert(ALTextView& view) { + // Vim has one caret: the others typing went in at go, before a count + // or a block puts in again at the main one. + view.singleSelection(); const ALTextDocument& d = view.document(); // What was typed, again as many times as the count said: made once and // put in as one edit, not an edit a time. @@ -4099,13 +4109,22 @@ bool ALVimKeymap::insert(ALTextView& view, const Input& input) } if (mMode == Mode::Replace) { - // Over the character under the caret, if there is one. - const ALTextPos from = view.caret(); - if (!atLineEnd(d, from)) + // Over the character under each caret, where there is one. + const auto over = [&d](const ALTextPos& from) { return atLineEnd(d, from) ? ALTextRange(from, from) : ALTextRange(from, d.nextCluster(from)); }; + if (view.hasOtherSelections()) + { + std::vector others; + for (const ALTextRange& one : view.otherSelections()) + { + others.push_back(over(one.end)); + } + view.setSelections(over(view.caret()), std::move(others)); + } + else { - view.setSelection(ALTextRange(from, d.nextCluster(from))); + view.setSelection(over(view.caret())); } - view.insertText(utf8Of(input.ch)); + view.typeText(utf8Of(input.ch)); mTyped += utf8Of(input.ch); return true; } diff --git a/indra/llui/tests/alcodeeditor_test.cpp b/indra/llui/tests/alcodeeditor_test.cpp index 605b663cbc..c0d6678a20 100644 --- a/indra/llui/tests/alcodeeditor_test.cpp +++ b/indra/llui/tests/alcodeeditor_test.cpp @@ -2184,6 +2184,21 @@ namespace tut e.setCaret(ALTextPos(3, 0)); e.paste(); ensure_equals("a selection copied goes in where the caret is", e.text(), std::string("two\nthree\ne\ntwotwo")); + + // Several, one selecting something: what is copied is what a cut + // takes, the selections' text, not a caret's line too. + e.setText("alpha\nbeta"); + e.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 2)), { ALTextRange(ALTextPos(1, 1), ALTextPos(1, 1)) }); + e.copy(); + LLClipboard::instance().pasteFromClipboard(held); + ensure_equals("the selection's alone", held, std::string("al")); + e.cut(); + ensure_equals("and the cut takes that alone", e.text(), std::string("pha\nbeta")); + + // A jump to a line leaves one caret there. + e.setSelections(ALTextRange(ALTextPos(0, 1), ALTextPos(0, 1)), { ALTextRange(ALTextPos(1, 1), ALTextPos(1, 1)) }); + e.goToLine(1); + ensure("one caret, at the line", !e.hasOtherSelections() && e.caret() == ALTextPos(1, 0)); } template<> template<> @@ -2230,8 +2245,14 @@ namespace tut e.setCaret(ALTextPos(0, 10)); key(KEY_RIGHT, part); ensure("a part", e.caret() == ALTextPos(0, 12)); +#if LL_DARWIN key(KEY_RIGHT, part | MASK_SHIFT); - ensure("and a part selected", e.selection().normalised() == ALTextRange(ALTextPos(0, 12), ALTextPos(0, 15))); +#else + // A key of its own only on the Mac: Control-Shift-Alt with an + // arrow grows a column elsewhere. + e.perform(ALEditorCommand::SelectSubwordRight); +#endif + ensure("and a part selected",e.selection().normalised() == ALTextRange(ALTextPos(0, 12), ALTextPos(0, 15))); e.setCaret(ALTextPos(0, 18)); key(KEY_BACKSPACE, word); ensure_equals("a word taken back is the name", e.text(), std::string("ll.Say(0, );")); @@ -2305,7 +2326,7 @@ namespace tut template<> template<> void alcodeeditor_object::test<65>() { - set_test_name("Select Next Occurrence takes the name, then its places in turn, going round; what is typed goes into every place taken, one step; Change All takes every place"); + set_test_name("Select Next Occurrence takes the name, then its places in turn, going round, each a selection; what is typed goes into every place taken, one step; Change All selects every place"); ALCodeEditor& e = make("local x = 1\nx = x + xy\nprint(x)", "slua"); e.setAutoComplete(false); e.setAutoClose(false); @@ -2313,29 +2334,31 @@ namespace tut key('D', MASK_CONTROL); ensure_equals("the name", e.selectedText(), std::string("x")); key('D', MASK_CONTROL); - ensure("the next place taken, the selection kept", e.selection().normalised() == ALTextRange(ALTextPos(1, 0), ALTextPos(1, 1))); + ensure("the next place taken, the main selection now", e.selection().normalised() == ALTextRange(ALTextPos(1, 4), ALTextPos(1, 5))); + ensure("the first kept beside it", e.otherSelections().size() == 1 && e.otherSelections()[0] == ALTextRange(ALTextPos(1, 0), ALTextPos(1, 1))); type("count"); ensure_equals("typed into both, not into the longer name", e.text(), std::string("local x = 1\ncount = count + xy\nprint(x)")); e.undo(); ensure_equals("one step back takes back both", e.text(), std::string("local x = 1\nx = x + xy\nprint(x)")); - ensure("and ends it", e.placeholders().empty()); e.setCaret(ALTextPos(2, 6)); + e.singleSelection(); key('D', MASK_CONTROL); key('D', MASK_CONTROL); key('D', MASK_CONTROL); type("n"); ensure_equals("going round from the last to the first", e.text(), std::string("local n = 1\nn = x + xy\nprint(n)")); key(KEY_ESCAPE); - ensure("Escape lets go", e.placeholders().empty()); + ensure("Escape lets go", !e.hasOtherSelections()); e.setText("local x = 1\nx = x + xy\nprint(\"x\")"); e.setCaret(ALTextPos(0, 6)); key('L', MASK_CONTROL | MASK_SHIFT); + ensure_equals("Change All: every place selected", e.otherSelections().size(), static_cast(3)); type("n"); - ensure_equals("Change All: every place of the name, as written", e.text(), std::string("local n = 1\nn = n + xy\nprint(\"n\")")); - e.setCaret(ALTextPos(2, 0)); - ensure("the caret off the line: let go", e.placeholders().empty()); + ensure_equals("every place of the name, as written", e.text(), std::string("local n = 1\nn = n + xy\nprint(\"n\")")); + e.goTo(ALTextPos(2, 0)); + ensure("gone elsewhere: one caret", !e.hasOtherSelections()); type("-- "); ensure_equals("and typing is typing again", e.document().line(2), std::string("-- print(\"n\")")); } @@ -2615,4 +2638,112 @@ namespace tut key(KEY_ESCAPE); ensure("and the box goes with the list", !fixes->sideShown() && !fixes->getVisible()); } + + template<> template<> + void alcodeeditor_object::test<73>() + { + set_test_name("pairs at every caret: closed, typed over and taken away together at each, a selection wrapped; a character typed as ever where it pairs nothing"); + ALCodeEditor& e = make("a\nb\nc"); + e.setAutoClose(true); + e.setSelections(ALTextRange(ALTextPos(0, 1), ALTextPos(0, 1)), { ALTextRange(ALTextPos(1, 1), ALTextPos(1, 1)), ALTextRange(ALTextPos(2, 1), ALTextPos(2, 1)) }); + type("("); + ensure_equals("closed at each", e.text(), std::string("a()\nb()\nc()")); + ensure("the main caret between", e.caret() == ALTextPos(0, 2)); + type("x)"); + ensure_equals("each closer typed over", e.text(), std::string("a(x)\nb(x)\nc(x)")); + ensure("past it", e.caret() == ALTextPos(0, 4)); + type("["); + key(KEY_BACKSPACE); + ensure_equals("one Backspace takes each pair", e.text(), std::string("a(x)\nb(x)\nc(x)")); + e.undo(); + ensure_equals("the Backspace undone", e.text(), std::string("a(x)[]\nb(x)[]\nc(x)[]")); + + e.setText("one two"); + e.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 3)), { ALTextRange(ALTextPos(0, 4), ALTextPos(0, 7)) }); + type("\""); + ensure_equals("each selection wrapped", e.text(), std::string("\"one\" \"two\"")); + e.setText("x\n// y"); + e.setSelections(ALTextRange(ALTextPos(0, 1), ALTextPos(0, 1)), { ALTextRange(ALTextPos(1, 4), ALTextPos(1, 4)) }); + type("("); + ensure_equals("closed where it is code, typed as ever in the comment", e.text(), std::string("x()\n// y(")); + + // A closer typed over at one caret and typed plain at another: the + // plain one comes out as it would alone. + e.setText("{\n \nx"); + e.setCaret(ALTextPos(2, 1)); + type("{"); + e.setSelections(ALTextRange(ALTextPos(2, 2), ALTextPos(2, 2)), { ALTextRange(ALTextPos(1, 8), ALTextPos(1, 8)) }); + type("}"); + ensure_equals("typed over at one", e.document().line(2), std::string("x{}")); + ensure_equals("brought out at the other", e.document().line(1), std::string("}")); + } + + template<> template<> + void alcodeeditor_object::test<74>() + { + set_test_name("Select Next Occurrence at several carets takes the word at each, then the next place as the main one; a completion accepted goes in at each caret, its first parameter chosen at each"); + ALCodeEditor& e = make("one\none two\none", "slua"); + e.setAutoComplete(false); + e.setAutoClose(false); + const auto at = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + e.setSelections(at(0, 1), { at(2, 1) }); + key('D', MASK_CONTROL); + ensure("the word at the main caret", e.selection() == ALTextRange(ALTextPos(0, 0), ALTextPos(0, 3))); + ensure("and at the other", e.otherSelections().size() == 1 && e.otherSelections()[0] == ALTextRange(ALTextPos(2, 0), ALTextPos(2, 3))); + key('D', MASK_CONTROL); + ensure("the next place not taken, the main one", e.selection() == ALTextRange(ALTextPos(1, 0), ALTextPos(1, 3)) && e.otherSelections().size() == 2); + type("1"); + ensure_equals("typed at each", e.text(), std::string("1\n1 two\n1")); + + e.setText("myC\n myC"); + e.setCompletionProvider([](const ALTextPos&, std::string_view, std::vector& out) { + ALCodeEditor::Completion c; + c.text = "myCall"; + c.kind = ALSyntaxKind::Function; + c.detail = "myCall(integer channel, string msg)"; + out.push_back(c); + }); + e.setSelections(at(0, 3), { at(1, 5) }); + key(' ', MASK_CONTROL); + ensure("the list at the main caret, the other kept", e.completionOpen() && e.hasOtherSelections()); + ensure("the call offered", !e.completions().empty() && e.completions()[0].text == "myCall"); + key(KEY_RETURN); + ensure_equals("in at each, over what was typed", e.text(), std::string("myCall(channel, msg)\n myCall(channel, msg)")); + ensure("the first parameter chosen at the main caret", e.selection() == ALTextRange(ALTextPos(0, 7), ALTextPos(0, 14))); + ensure("and at the other", e.otherSelections().size() == 1 && e.otherSelections()[0] == ALTextRange(ALTextPos(1, 9), ALTextPos(1, 16))); + e.undo(); + ensure_equals("one step", e.text(), std::string("myC\n myC")); + } + + template<> template<> + void alcodeeditor_object::test<75>() + { + set_test_name("a caret besides the main one put on a line folded away opens it, as the main one does; folding moves the others out to the fold's line, as it does the main one"); + ALCodeEditor& e = make("default\n{\n state_entry()\n {\n llSay(0, \"a\");\n llSay(0, \"b\");\n }\n}\nx"); + const auto caretAt = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + ensure("folds", e.foldAt(2)); + e.setSelections(caretAt(8, 0), { caretAt(4, 8) }); + ensure("the other caret's line opened", !e.isFolded(2) && !e.layout().hidden(4)); + + e.setSelections(caretAt(8, 0), { caretAt(4, 8), ALTextRange(ALTextPos(5, 8), ALTextPos(5, 13)) }); + ensure("folds again", e.foldAt(2)); + ensure("the main one where it was", e.selection() == caretAt(8, 0)); + ensure("the others out of the fold, at the end of its line, as one", e.otherSelections().size() == 1 && e.otherSelections()[0] == caretAt(2, 17)); + ensure("and the fold kept", e.isFolded(2)); + + e.unfoldAll(); + e.setSelections(caretAt(8, 0), { caretAt(4, 8) }); + e.foldAll(); + ensure("folding all moves them out as well", e.otherSelections().size() == 1 && !e.layout().hidden(e.otherSelections()[0].end.line)); + ensure("into sight above", e.otherSelections()[0] == caretAt(0, 7)); + + // The main one folded away too: every block folds, none opened + // again for a caret in it. + e.setText("f()\n{\n llSay(0, \"a\");\n}\ng()\n{\n llSay(0, \"b\");\n}\n"); + e.setSelections(caretAt(2, 4), { caretAt(6, 4) }); + e.foldAll(); + ensure("both folded", e.isFolded(0) && e.isFolded(4)); + ensure("the main one at its fold's line", e.selection() == caretAt(0, 3)); + ensure("the other at its own", e.otherSelections().size() == 1 && e.otherSelections()[0] == caretAt(4, 3)); + } } diff --git a/indra/llui/tests/altextcarets_test.cpp b/indra/llui/tests/altextcarets_test.cpp new file mode 100644 index 0000000000..18a7966532 --- /dev/null +++ b/indra/llui/tests/altextcarets_test.cpp @@ -0,0 +1,218 @@ +/** + * @file tests/altextcarets_test.cpp + * @brief The selections a text view has besides its main one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../altextcarets.h" + +#include "../test/lltut.h" + +#include +#include + +namespace +{ + ALTextRange caret(S32 line, S32 column) + { + return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); + } + + // From an anchor to a caret, on one line. + ALTextRange sel(S32 line, S32 anchor, S32 caret_column) + { + return ALTextRange(ALTextPos(line, anchor), ALTextPos(line, caret_column)); + } + + std::string said(const ALTextRange& r) + { + return std::to_string(r.begin.line) + ":" + std::to_string(r.begin.column) + "-" + std::to_string(r.end.line) + ":" + + std::to_string(r.end.column); + } + + std::string said(const std::vector& all) + { + std::string out; + for (const ALTextRange& r : all) + { + out += (out.empty() ? "" : " ") + said(r); + } + return out; + } +} + +namespace tut +{ + struct altextcarets_data + { + }; + typedef test_group altextcarets_group; + typedef altextcarets_group::object altextcarets_object; + altextcarets_group altextcarets_instance("altextcarets"); + + template<> template<> + void altextcarets_object::test<1>() + { + set_test_name("a caret is pushed past what is typed at it; a selection keeps what it held, what goes in at either edge outside it"); + ALTextDocument doc("abcdef"); + // Typed at 2. + ALTextDocument::Edit typed = doc.insert(ALTextPos(0, 2), "XY"); + ensure_equals("a caret at it pushed past", said(ALTextCarets::slid(caret(0, 2), typed)), said(caret(0, 4))); + ensure_equals("one before it stays", said(ALTextCarets::slid(caret(0, 1), typed)), said(caret(0, 1))); + ensure_equals("one after it moves along", said(ALTextCarets::slid(caret(0, 5), typed)), said(caret(0, 7))); + ensure_equals("a selection that begins at it is pushed whole", said(ALTextCarets::slid(sel(0, 2, 4), typed)), said(sel(0, 4, 6))); + ensure_equals("one that ends at it stays, what went in after it", said(ALTextCarets::slid(sel(0, 0, 2), typed)), said(sel(0, 0, 2))); + ensure_equals("and backwards the same, the caret still at its start", said(ALTextCarets::slid(sel(0, 2, 0), typed)), said(sel(0, 2, 0))); + ensure_equals("one over it grows by it", said(ALTextCarets::slid(sel(0, 1, 3), typed)), said(sel(0, 1, 5))); + ensure_equals("and keeps its way", said(ALTextCarets::slid(sel(0, 3, 1), typed)), said(sel(0, 5, 1))); + + // "abXYcdef": "Ycd" taken. + ALTextDocument::Edit taken = doc.remove(ALTextRange(ALTextPos(0, 3), ALTextPos(0, 6))); + ensure_equals("a caret in what was taken goes to where it began", said(ALTextCarets::slid(caret(0, 5), taken)), said(caret(0, 3))); + ensure_equals("a selection over its start is cut back to it", said(ALTextCarets::slid(sel(0, 1, 4), taken)), said(sel(0, 1, 3))); + ensure_equals("one over its end loses what it took", said(ALTextCarets::slid(sel(0, 7, 4), taken)), said(sel(0, 4, 3))); + ensure_equals("one inside it a caret where it began", said(ALTextCarets::slid(sel(0, 4, 5), taken)), said(caret(0, 3))); + } + + template<> template<> + void altextcarets_object::test<2>() + { + set_test_name("those that overlap become one, and so do a caret and what it touches; two selections that only touch stay two"); + ALTextCarets carets; + carets.assign({ sel(0, 0, 3), sel(0, 3, 6) }); + ensure_equals("two side by side stay two", said(carets.selections()), said(std::vector{ sel(0, 0, 3), sel(0, 3, 6) })); + carets.assign({ sel(0, 0, 4), sel(0, 3, 6) }); + ensure_equals("two over each other one", said(carets.selections()), said(sel(0, 0, 6))); + carets.assign({ sel(0, 0, 3), caret(0, 3) }); + ensure_equals("a caret at a selection's end in it", said(carets.selections()), said(sel(0, 0, 3))); + carets.assign({ caret(1, 2), sel(1, 6, 2) }); + ensure_equals("one at its start too, the selection's way kept", said(carets.selections()), said(sel(1, 6, 2))); + carets.assign({ caret(2, 1), caret(2, 1), caret(0, 1) }); + ensure_equals("two carets at one place one, in order", said(carets.selections()), said(std::vector{ caret(0, 1), caret(2, 1) })); + carets.assign({ sel(0, 0, 2), sel(0, 1, 4), sel(0, 3, 8), caret(0, 10) }); + ensure_equals("a run of overlaps one, the one after them not", said(carets.selections()), + said(std::vector{ sel(0, 0, 8), caret(0, 10) })); + carets.add(caret(0, 8)); + ensure_equals("one more added merges as it goes in", said(carets.selections()), said(std::vector{ sel(0, 0, 8), caret(0, 10) })); + carets.add(caret(0, 9)); + ensure_equals("and one in a gap stays", carets.size(), static_cast(3)); + } + + template<> template<> + void altextcarets_object::test<3>() + { + set_test_name("the main selection takes in any it meets, and keeps its way; a caret as main takes the way of what it took in"); + ALTextCarets carets; + carets.assign({ caret(0, 2), sel(0, 5, 8), caret(3, 0) }); + ALTextRange main = sel(0, 5, 0); + ensure("the main one as it was", !carets.merge(main)); + ensure_equals("and still so", said(main), said(sel(0, 5, 0))); + ensure_equals("the caret it covers taken in, not the selection it only touches", said(carets.selections()), + said(std::vector{ sel(0, 5, 8), caret(3, 0) })); + + main = sel(0, 2, 6); + ensure("changed again", carets.merge(main)); + ensure_equals("over the selection now, its own way kept", said(main), said(sel(0, 2, 8))); + ensure_equals("the others left", said(carets.selections()), said(caret(3, 0))); + + carets.assign({ sel(1, 9, 3) }); + main = caret(1, 9); + ensure("a caret at a selection's end changed", carets.merge(main)); + ensure_equals("it is the selection, run its way", said(main), said(sel(1, 9, 3))); + ensure("nothing else", carets.empty()); + + carets.assign({ caret(5, 0) }); + main = caret(0, 0); + ensure("apart, nothing changes", !carets.merge(main)); + ensure_equals("and nothing goes", carets.size(), static_cast(1)); + } + + template<> template<> + void altextcarets_object::test<4>() + { + set_test_name("an edit slides each along, those it brought together merged; those before it and below it untouched"); + ALTextDocument doc("one two three\nfour five\nsix"); + ALTextCarets carets; + carets.assign({ caret(0, 1), caret(0, 5), sel(0, 8, 11), caret(1, 2), caret(2, 1) }); + // "two thr" taken: the caret in it and the selection's start go to + // where it began, and meet. + carets.apply(doc.remove(ALTextRange(ALTextPos(0, 4), ALTextPos(0, 11)))); + ensure_equals("cut back and merged", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), caret(1, 2), caret(2, 1) })); + // A line put in above the second: those below move down a line. + carets.apply(doc.insert(ALTextPos(1, 0), "new\n")); + ensure_equals("moved down", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), caret(2, 2), caret(3, 1) })); + // A line joined to the one before: the caret on it goes onto that one. + carets.apply(doc.remove(ALTextRange(ALTextPos(2, 4), ALTextPos(3, 0)))); + ensure_equals("onto the line it was joined to", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), caret(2, 2), caret(2, 5) })); + ensure_equals("the text as the carets have it", doc.text(), std::string("one ee\nnew\nfoursix")); + } + + template<> template<> + void altextcarets_object::test<5>() + { + set_test_name("a batch's stretches slide each by the stretches before it; a caret in one goes to where it begins"); + ALTextDocument doc("a b c d e"); + ALTextCarets carets; + carets.assign({ caret(0, 1), caret(0, 3), sel(0, 4, 5), caret(0, 9) }); + // "b" made "BB", and "d" made "" -- the caret at 3 is right after b. + carets.apply(doc.replaceMany({ { ALTextRange(ALTextPos(0, 2), ALTextPos(0, 3)), "BB" }, { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 7)), "" } })); + ensure_equals("each along by what went before it", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), sel(0, 5, 6), caret(0, 9) })); + ensure_equals("the text", doc.text(), std::string("a BB c e")); + // Text put in at the selection's start and at its end: it keeps + // what it held; the caret at the end of the text pushed. + carets.apply(doc.replaceMany({ { ALTextRange(ALTextPos(0, 5), ALTextPos(0, 5)), "<" }, { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 6)), ">" }, + { ALTextRange(ALTextPos(0, 9), ALTextPos(0, 9)), "!" } })); + ensure_equals("the selection still over c alone, the last caret past the !", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), sel(0, 6, 7), caret(0, 12) })); + ensure_equals("the text again", doc.text(), std::string("a BB e!")); + } + + template<> template<> + void altextcarets_object::test<6>() + { + set_test_name("those that may lie on a line: one that began above and reaches it, and those that begin on it"); + ALTextCarets carets; + carets.assign({ caret(0, 0), ALTextRange(ALTextPos(4, 2), ALTextPos(1, 3)), caret(2, 0), caret(2, 4), caret(3, 1) }); + const auto on = [&](S32 line) { + std::vector out; + for (auto [it, end] = carets.onLine(line); it != end; ++it) + { + const ALTextRange r = it->normalised(); + if (r.begin.line <= line && line <= r.end.line) + { + out.push_back(*it); + } + } + return said(out); + }; + // The long one takes in what it covers. + ensure_equals("all, merged", carets.size(), static_cast(2)); + ensure_equals("line 0", on(0), said(caret(0, 0))); + ensure_equals("line 2 is the long one's", on(2), said(ALTextRange(ALTextPos(4, 2), ALTextPos(1, 3)))); + ensure_equals("line 5 nothing", on(5), std::string()); + } +} diff --git a/indra/llui/tests/altextdocument_test.cpp b/indra/llui/tests/altextdocument_test.cpp index 5d5c8392bc..c87ba99854 100644 --- a/indra/llui/tests/altextdocument_test.cpp +++ b/indra/llui/tests/altextdocument_test.cpp @@ -462,4 +462,26 @@ namespace tut ensure("off a line's end to the next", d.nextCluster(ALTextPos(0, static_cast(lines[0].size()))) == ALTextPos(1, 0)); ensure("back off its start to the one before", d.prevCluster(ALTextPos(1, 0)) == ALTextPos(0, static_cast(lines[0].size()))); } + + template<> template<> + void altextdocument_object::test<20>() + { + set_test_name("a place moved by an edit, pushed by text put in at it or not; and the stretch a place stood in, a batch's own, not the text between"); + ALTextDocument doc("abcdef"); + const ALTextDocument::Edit typed = doc.insert(ALTextPos(0, 2), "XY"); + ensure("pushed", typed.placed(ALTextPos(0, 2)) == ALTextPos(0, 4)); + ensure("not pushed", typed.placed(ALTextPos(0, 2), false) == ALTextPos(0, 2)); + ensure("after it moved either way", typed.placed(ALTextPos(0, 3), false) == ALTextPos(0, 5)); + ensure("nothing stood in what was put in", !typed.replacedAround(ALTextPos(0, 2))); + + // "abXYcdef": "XY" and "ef" replaced at once. + const ALTextDocument::Edit batch = + doc.replaceMany({ { ALTextRange(ALTextPos(0, 2), ALTextPos(0, 4)), "-" }, { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 8)), "" } }); + ensure_equals("made", doc.text(), std::string("ab-cd")); + ensure("in the first", batch.replacedAround(ALTextPos(0, 3)) == ALTextRange(ALTextPos(0, 2), ALTextPos(0, 4))); + ensure("at the second's start", batch.replacedAround(ALTextPos(0, 6)) == ALTextRange(ALTextPos(0, 6), ALTextPos(0, 8))); + ensure("not between them", !batch.replacedAround(ALTextPos(0, 4)) && !batch.replacedAround(ALTextPos(0, 5))); + ensure("nor at an end", !batch.replacedAround(ALTextPos(0, 8))); + ensure("between them, moved by the first", batch.placed(ALTextPos(0, 5)) == ALTextPos(0, 4)); + } } diff --git a/indra/llui/tests/altextediting_test.cpp b/indra/llui/tests/altextediting_test.cpp index 380d7c99bc..dc3c523cbe 100644 --- a/indra/llui/tests/altextediting_test.cpp +++ b/indra/llui/tests/altextediting_test.cpp @@ -28,7 +28,9 @@ #include "../test/lltut.h" +#include #include +#include namespace tut { @@ -48,6 +50,31 @@ namespace tut } }; + // Several selections' groups made as one: the text after, and each + // selection where it is, "-" for one left to slide. + static std::string combined(const std::string& text, std::vector groups, size_t count, std::string& placed) + { + Combined all = combine(std::move(groups), count); + ALTextDocument doc(text); + std::vector> edits; + for (const Replacement& one : all.replacements) + { + edits.emplace_back(one.range, one.text); + } + doc.replaceMany(std::move(edits)); + placed.clear(); + for (const std::optional& one : all.selections) + { + placed += one ? llformat("%d:%d-%d:%d ", one->begin.line, one->begin.column, one->end.line, one->end.column) : std::string("- "); + } + return doc.text(); + } + + static ALTextRange at(S32 line, S32 column) + { + return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); + } + typedef test_group altextediting_group; typedef altextediting_group::object altextediting_object; altextediting_group altextediting_instance("altextediting"); @@ -166,4 +193,108 @@ namespace tut ensure_equals("copied to the top", applied(text, first), std::string("b\na\nb\nc\nd\ne")); ensure("the caret on it", first.caret == ALTextPos(0, 0)); } + + template<> template<> + void altextediting_object::test<6>() + { + set_test_name("groups made as one: each selection moved by the groups before it; one over a group before it left out; one that replaces nothing placed by those before"); + // Typed at two carets on a line, and a line broken at a third. + std::vector groups(3); + groups[0].replacements.push_back({ at(0, 1), "XY" }); + groups[0].placed.emplace_back(0, at(0, 3)); + groups[1].replacements.push_back({ at(0, 5), "XY" }); + groups[1].placed.emplace_back(1, at(0, 7)); + groups[2].replacements.push_back({ at(1, 2), "\n" }); + groups[2].placed.emplace_back(2, at(2, 0)); + std::string placed; + ensure_equals("all made", combined("abc def\nghi", groups, 3, placed), std::string("aXYbc dXYef\ngh\ni")); + ensure_equals("each moved by those before it", placed, std::string("0:3-0:3 0:9-0:9 2:0-2:0 ")); + + // A selection deleted over where the second typed: the one that + // comes first in the text stands, the second is left out. + Group taken; + taken.replacements.push_back({ ALTextRange(ALTextPos(0, 4), ALTextPos(0, 6)), "" }); + taken.placed.emplace_back(3, at(0, 4)); + // And a caret that moves over a closer, replacing nothing. + Group over; + over.placed.emplace_back(4, at(1, 1)); + groups.push_back(taken); + groups.push_back(over); + ensure_equals("the second left out", combined("abc def\nghi", groups, 5, placed), std::string("aXYbc f\ngh\ni")); + ensure_equals("its caret not placed, the others moved", placed, std::string("0:3-0:3 - 2:0-2:0 0:6-0:6 1:1-1:1 ")); + } + + template<> template<> + void altextediting_object::test<7>() + { + set_test_name("selections gathered into runs over the same lines, or over lines next to each other too"); + const std::vector selections = { at(0, 0), ALTextRange(ALTextPos(1, 0), ALTextPos(2, 3)), at(2, 5), at(4, 0), at(5, 1), + ALTextRange(ALTextPos(6, 0), ALTextPos(8, 0)), at(8, 0) }; + const auto said = [](const std::vector& runs) { + std::string out; + for (const LineRun& run : runs) + { + out += llformat("%d-%d:", run.first, run.last); + for (const size_t i : run.selections) + { + out += llformat("%d", static_cast(i)); + } + out += " "; + } + return out; + }; + ensure_equals("over the same lines", said(lineRuns(selections, false)), std::string("0-0:0 1-2:12 4-4:3 5-5:4 6-7:5 8-8:6 ")); + ensure_equals("and next to each other", said(lineRuns(selections, true)), std::string("0-2:012 4-8:3456 ")); + } + + template<> template<> + void altextediting_object::test<8>() + { + set_test_name("lines duplicated and moved at several selections: once for those on the same lines, each selection with its lines"); + std::string placed; + ensure_equals("two carets on a line copy it once", combined("a\nb\nc\nd", duplicateLines(ALTextDocument("a\nb\nc\nd"), { at(0, 0), at(0, 1), at(2, 0) }), 3, placed), + std::string("a\na\nb\nc\nc\nd")); + ensure_equals("each on its copy", placed, std::string("1:0-1:0 1:1-1:1 4:0-4:0 ")); + + const std::string text = "0\n1\n2\n3\n4\n5"; + const ALTextDocument doc(text); + ensure_equals("lines next to each other move as one", combined(text, moveLines(doc, { at(1, 0), at(2, 0), at(4, 0) }, -1), 3, placed), + std::string("1\n2\n0\n4\n3\n5")); + ensure_equals("each with its line", placed, std::string("0:0-0:0 1:0-1:0 3:0-3:0 ")); + ensure("nothing where one is at the top already", moveLines(doc, { at(0, 0), at(3, 0) }, -1).empty()); + ensure("nor at the bottom", moveLines(doc, { at(3, 0), at(5, 1) }, 1).empty()); + } + + template<> template<> + void altextediting_object::test<9>() + { + set_test_name("lines deleted and joined at several selections: lines next to each other as one, each caret where its run leaves it"); + std::string placed; + const std::string text = "0\n1\n2\n3\n4"; + ensure_equals("the two in the middle and the last", combined(text, deleteLines(ALTextDocument(text), { at(1, 0), at(2, 0), at(4, 0) }), 3, placed), + std::string("0\n3")); + ensure_equals("the carets on the line that took their place", placed, std::string("1:0-1:0 1:0-1:0 1:0-1:0 ")); + + const std::string lines = "a\n b\nc\n d\ne"; + ensure_equals("each caret's line and the next joined", combined(lines, joinLines(ALTextDocument(lines), { at(0, 0), at(2, 0) }), 2, placed), + std::string("a b\nc d\ne")); + ensure_equals("each where its join is", placed, std::string("0:1-0:1 1:1-1:1 ")); + ensure_equals("carets on lines a join shares joined as one run", combined(lines, joinLines(ALTextDocument(lines), { at(0, 0), at(1, 1) }), 2, placed), + std::string("a b c\n d\ne")); + } + + template<> template<> + void altextediting_object::test<10>() + { + set_test_name("comments in or out the same way at every selection: in where any line is not, each caret kept in its text and each selection its lines whole"); + std::string placed; + const std::string text = "a\n // b\nc"; + const std::string in = combined(text, toggleComment(ALTextDocument(text), { at(0, 1), at(1, 5), ALTextRange(ALTextPos(2, 0), ALTextPos(2, 1)) }, "//"), 3, placed); + ensure_equals("in at all of them, the commented line too", in, std::string("// a\n // // b\n// c")); + ensure_equals("carets along, the selection its line whole", placed, std::string("0:4-0:4 1:8-1:8 2:0-2:4 ")); + const std::string out = combined(in, toggleComment(ALTextDocument(in), { at(0, 4), at(2, 2) }, "//"), 2, placed); + ensure_equals("out where every one is", out, std::string("a\n // // b\nc")); + ensure_equals("a caret inside the token where it began", placed, std::string("0:1-0:1 2:0-2:0 ")); + ensure("nothing where no line says anything", toggleComment(ALTextDocument("\n \n"), { at(0, 0), at(1, 1) }, "//").empty()); + } } diff --git a/indra/llui/tests/altextindent_test.cpp b/indra/llui/tests/altextindent_test.cpp index d168303fdb..40849fc1c0 100644 --- a/indra/llui/tests/altextindent_test.cpp +++ b/indra/llui/tests/altextindent_test.cpp @@ -423,4 +423,37 @@ namespace tut std::string("a\n\n\nb\n c")); ensure_equals("out by two", applied(ALTextIndent::shiftLines(doc, 0, 4, 2, false, hard)), std::string("a\n\n\nb\nc")); } + + template<> template<> + void altextindent_object::test<13>() + { + set_test_name("lines in and out at several selections: once for those over the same lines, each selection moved with its lines"); + const std::string text = "a\nb\nc\n d"; + const ALTextDocument doc(text); + const auto made = [](const std::string& before, const std::vector& groups, std::string& placed) { + ALTextEditing::Combined combined = ALTextEditing::combine(groups, 3); + ALTextDocument after(before); + std::vector> edits; + for (const ALTextEditing::Replacement& one : combined.replacements) + { + edits.emplace_back(one.range, one.text); + } + after.replaceMany(std::move(edits)); + placed.clear(); + for (const std::optional& one : combined.selections) + { + placed += one ? llformat("%d:%d-%d:%d ", one->begin.line, one->begin.column, one->end.line, one->end.column) : std::string("- "); + } + return after.text(); + }; + std::string placed; + // Two selections over lines 0 and 1, which share line 1, and one on + // line 3. + const std::vector selections = { ALTextRange(ALTextPos(0, 0), ALTextPos(1, 1)), ALTextRange(ALTextPos(1, 1), ALTextPos(1, 0)), + ALTextRange(ALTextPos(3, 5), ALTextPos(3, 4)) }; + ensure_equals("each line in once", made(text, indentLines(doc, selections, true, spaces), placed), std::string(" a\n b\nc\n d")); + ensure_equals("each selection moved with its lines", placed, std::string("0:4-1:5 1:5-1:4 3:9-3:8 ")); + ensure_equals("and out", made(text, indentLines(doc, selections, false, spaces), placed), std::string("a\nb\nc\nd")); + ensure_equals("those with nothing to take left to slide; the other moved", placed, std::string("- - 3:1-3:0 ")); + } } diff --git a/indra/llui/tests/altextundo_test.cpp b/indra/llui/tests/altextundo_test.cpp index 8fe914af2a..6d692ef7e9 100644 --- a/indra/llui/tests/altextundo_test.cpp +++ b/indra/llui/tests/altextundo_test.cpp @@ -777,4 +777,69 @@ namespace tut undo.undo(); ensure_equals("not the other's", doc.text(), std::string("qpsx12abcy")); } + + template<> template<> + void altextundo_object::test<24>() + { + set_test_name("a step keeps the other selections before and after: a run the first's before and the last's after; written and read back"); + doc.setText("abc\nabc\nabc"); + const auto at = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + const auto said = [](const std::vector& all) { + std::string out; + for (const ALTextRange& r : all) + { + out += llformat("%d:%d-%d:%d ", r.begin.line, r.begin.column, r.end.line, r.end.column); + } + return out; + }; + // Typed at the main caret, with others on the lines below: each + // edit noted with the others as they stood, and settled with them + // as they stand after. + now += 0.1; + ALTextDocument::Edit first = doc.insert(ALTextPos(0, 1), "x"); + undo.record(first, at(0, 1), first.endAfter(), now, { at(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 0)) }); + undo.settle(at(0, 2), { at(1, 2), ALTextRange(ALTextPos(2, 4), ALTextPos(2, 0)) }); + now += 0.1; + ALTextDocument::Edit second = doc.insert(ALTextPos(0, 2), "y"); + undo.record(second, at(0, 2), second.endAfter(), now, { at(1, 2) }); + undo.settle(at(0, 3), { at(1, 3) }); + // Said again without them: they stay as they were said. + undo.settle(at(0, 3)); + + std::vector others; + std::optional back = undo.undo(&others); + ensure("one step", back.has_value() && doc.text() == "abc\nabc\nabc"); + ensure_equals("the others as they stood before the run", said(others), said({ at(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 0)) })); + std::optional ahead = undo.redo(&others); + ensure("and forward", ahead.has_value() && *ahead == at(0, 3)); + ensure_equals("as they stood after it", said(others), said({ at(1, 3) })); + + // Written out and read back over the same text. + ALTextDocument copy(doc.text()); + ALTextUndo again(copy); + ensure("read back", again.fromLLSD(undo.asLLSD())); + std::istringstream notation(undo.asNotation()); + LLSD written; + ensure("and in notation", LLSDSerialize::fromNotation(written, notation, LLSDSerialize::SIZE_UNLIMITED) > 0 && + written["undo"][0]["others_after"].size() == 1); + again.undo(&others); + ensure_equals("the others before, read back", said(others), said({ at(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 0)) })); + again.redo(&others); + ensure_equals("and after", said(others), said({ at(1, 3) })); + + // A step whose others after were never said: a redo leaves the + // caret alone; and one with none has none. + now += 5.0; + ALTextDocument::Edit third = doc.insert(ALTextPos(0, 0), "z"); + undo.record(third, at(0, 0), third.endAfter(), now, { at(2, 0) }); + undo.settle(at(0, 1)); + undo.undo(&others); + ensure_equals("before, as noted", said(others), said({ at(2, 0) })); + undo.redo(&others); + ensure("after, none", others.empty()); + now += 5.0; + type(ALTextPos(0, 0), "w"); + undo.undo(&others); + ensure("none where there were none", others.empty()); + } } diff --git a/indra/llui/tests/altextview_gl_test.cpp b/indra/llui/tests/altextview_gl_test.cpp index 92d1ea0920..89990c8682 100644 --- a/indra/llui/tests/altextview_gl_test.cpp +++ b/indra/llui/tests/altextview_gl_test.cpp @@ -501,4 +501,110 @@ namespace tut ensure("drawn under the text", drawn == 255); ensure("only the fade just under the baseline: " + std::to_string(under), under < 128); } + + // The carets besides the main one are drawn as it is, blinking with it: + // a frame with the main caret on one line and another on a second is + // the frame with the two the other way round. A selection besides the + // main one is washed as it is, on its own line only. And every caret + // in sight is the one draw. + template<> template<> + void altextview_gl_object::test<8>() + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(/*gl_textures=*/true); + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + std::string text; + for (S32 i = 0; i < 12; ++i) + { + text += "integer value = f(x, [y, z]);\n"; + } + ALTextView::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "view"; + p.rect = LLRect(0, H, W, 0); + p.default_text = text; + // The skin's colours are not loaded here: the caret's is given. + p.cursor_color = LLUIColor(LLColor4::white); + ALTextView* view = LLUICtrlFactory::create(p); + view->setFont(LLFontGL::getFontMonospace()); + view->setSelectionColor(LLUIColor(LLColor4(0.2f, 0.4f, 0.9f, 0.6f))); + // Shown, and still from frame to frame. + view->setCaretBlink(false); + view->setFocus(true); + + const auto frame = [&]() { + gl().clearFramebuffer(); + glEnable(GL_BLEND); + gGL.setSceneBlendType(LLRender::BT_ALPHA); + view->draw(); + gGL.flush(); + glDisable(GL_BLEND); + glFinish(); + return ll_test::readFramebufferRGBA(W, H); + }; + // Every pixel that differs between two frames lies within a line's + // band, and some do. + const auto only_on = [&](const std::string& what, const std::vector& a, const std::vector& b, S32 line) { + const S32 top = view->textRect().mTop - (view->layout().lineTop(line) - view->scrollY()) + 3; + const S32 bottom = top - 3 - view->layout().lineHeight(line) - 3; + bool some = false; + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const size_t i = (static_cast(y) * W + x) * 4; + if (a[i] != b[i] || a[i + 1] != b[i + 1] || a[i + 2] != b[i + 2]) + { + some = true; + ensure(what + ": changed at " + std::to_string(x) + "," + std::to_string(y) + ", off line " + std::to_string(line), + y <= top && y >= bottom); + } + } + } + ensure(what + ": something changed", some); + }; + const auto at = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + + view->setSelection(at(4, 0)); + const std::vector one = frame(); + view->addSelection(at(2, 5)); + const std::vector two = frame(); + only_on("another caret", one, two, 2); + view->setSelections(at(2, 5), { at(4, 0) }); + ensure("the two the other way round, the same frame", frame() == two); + + const ALTextRange span(ALTextPos(7, 2), ALTextPos(7, 10)); + view->setSelections(at(4, 0), { span }); + const std::vector other_span = frame(); + only_on("another selection, its caret at its end", one, other_span, 7); + view->setSelections(span, { at(4, 0) }); + ensure("the same as the main selection", frame() == other_span); + + // A caret on every line: the carets are one draw, not one each. + std::vector many; + for (S32 line = 1; line < 12; ++line) + { + many.push_back(at(line, line)); + } + view->setSelections(at(0, 0), many); + std::list capture; + gGL.beginList(&capture); + view->draw(); + gGL.flush(); + gGL.endList(); + glFinish(); + const size_t with_many = capture.size(); + capture.clear(); + view->singleSelection(); + gGL.beginList(&capture); + view->draw(); + gGL.flush(); + gGL.endList(); + glFinish(); + ensure("twelve carets cost what one does: " + std::to_string(with_many) + " against " + std::to_string(capture.size()), + with_many == capture.size()); + gFocusMgr.setKeyboardFocus(nullptr); + view->die(); + } } diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index 70d9fe748f..39a26934d7 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -65,6 +65,8 @@ namespace ll_test static void misspellingBudget(ALTextView& view, F32 seconds) { view.mMisspellingBudget = seconds; } static bool seeking(const ALTextView& view) { return view.mMisspellingSought.has_value(); } static void trimLayout(ALTextView& view) { view.trimLayout(); } + // Every selection, the main one among them, in the order they begin. + static std::vector selections(const ALTextView& view) { return view.selectionsInOrder(); } static S32 heldMost() { return ALTextView::LAYOUT_HELD_MOST; } // What a frame does before it draws, of what a test reaches: // Next Misspelling gone on with, and the primary selection offered. @@ -1927,4 +1929,495 @@ namespace tut ensure("let go", gFocusMgr.getMouseCapture() == nullptr && !ruler->dragging()); ensure("no tip on the map", v.handleToolTip(360, 100, MASK_NONE)); } + + // Ranges as text, for a failure to say what it found. + static std::string rangesSaid(const std::vector& all) + { + std::string out; + for (const ALTextRange& r : all) + { + out += llformat("%d:%d-%d:%d ", r.begin.line, r.begin.column, r.end.line, r.end.column); + } + return out; + } + + static ALTextRange caretRange(S32 line, S32 column) + { + return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); + } + + template<> template<> + void altextview_object::test<61>() + { + set_test_name("the selections besides the main one slide with an edit at the main caret, are taken in where it meets them, and go with a new text"); + ALTextView& v = make("alpha beta\ngamma delta\n"); + v.setCaret(ALTextPos(0, 0)); + v.addSelection(caretRange(1, 0)); + v.addSelection(ALTextRange(ALTextPos(1, 6), ALTextPos(1, 11))); + ensure_equals("two besides it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(1, 0), ALTextRange(ALTextPos(1, 6), ALTextPos(1, 11)) })); + ensure("the main one where it was", v.selection() == caretRange(0, 0)); + + // Put in at the main caret alone, as a caller does. + v.insertText("xy\n"); + ensure_equals("put in at the main caret only", v.text(), std::string("xy\nalpha beta\ngamma delta\n")); + ensure_equals("the others a line down", rangesSaid(v.otherSelections()), + rangesSaid({ caretRange(2, 0), ALTextRange(ALTextPos(2, 6), ALTextPos(2, 11)) })); + + // The main one selected backwards over part of the other selection: + // one selection over both, run the main one's way. + v.setSelection(ALTextRange(ALTextPos(2, 8), ALTextPos(2, 3))); + ensure_equals("the main one over both, backwards", rangesSaid({ v.selection() }), rangesSaid({ ALTextRange(ALTextPos(2, 11), ALTextPos(2, 3)) })); + ensure_equals("the caret it does not reach left", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(2, 0) })); + + // One added where the main one is: nothing more. + v.addSelection(caretRange(2, 5)); + ensure_equals("taken in", v.otherSelections().size(), static_cast(1)); + ensure("one caret again", v.singleSelection() && !v.hasOtherSelections()); + ensure("and nothing to let go of now", !v.singleSelection()); + + v.setSelections(caretRange(0, 0), { caretRange(1, 1) }); + v.setText("new"); + ensure("none with a new text", !v.hasOtherSelections()); + } + + template<> template<> + void altextview_object::test<62>() + { + set_test_name("undo puts the other selections back as they were before the step, and redo as they were after it"); + ALTextView& v = make("one\ntwo\nthree\n"); + const std::vector before = { caretRange(1, 3), ALTextRange(ALTextPos(2, 5), ALTextPos(2, 0)) }; + v.setSelections(caretRange(0, 3), before); + v.insertText("X\n"); + const std::vector after = { caretRange(2, 3), ALTextRange(ALTextPos(3, 5), ALTextPos(3, 0)) }; + ensure_equals("slid down a line", rangesSaid(v.otherSelections()), rangesSaid(after)); + + v.undo(); + ensure_equals("the text back", v.text(), std::string("one\ntwo\nthree\n")); + ensure("the main caret back", v.selection() == caretRange(0, 3)); + ensure_equals("the others as they were before", rangesSaid(v.otherSelections()), rangesSaid(before)); + v.redo(); + ensure("the main caret after", v.selection() == caretRange(1, 0)); + ensure_equals("the others as they were after", rangesSaid(v.otherSelections()), rangesSaid(after)); + + // A step made with none: undone, none; the one before it, the + // others it had. + v.singleSelection(); + v.undoJournal().breakRun(); + v.insertText("Y"); + v.undo(); + ensure("none before a step made with none", !v.hasOtherSelections()); + v.undo(); + ensure_equals("and the step before puts its own back", rangesSaid(v.otherSelections()), rangesSaid(before)); + } + + template<> template<> + void altextview_object::test<63>() + { + set_test_name("typing at every caret: each selection typed over, a run of it one step to undo, and undo and redo putting every caret back"); + ALTextView& v = make("one\ntwo\nthree\n"); + const std::vector before = { caretRange(1, 3), ALTextRange(ALTextPos(2, 0), ALTextPos(2, 5)) }; + v.setSelections(caretRange(0, 3), before); + type("ab"); + ensure_equals("at each", v.text(), std::string("oneab\ntwoab\nab\n")); + ensure("the main caret after it", v.selection() == caretRange(0, 5)); + const std::vector after = { caretRange(1, 5), caretRange(2, 2) }; + ensure_equals("each other after it", rangesSaid(v.otherSelections()), rangesSaid(after)); + v.undo(); + ensure_equals("one step", v.text(), std::string("one\ntwo\nthree\n")); + ensure("the main caret back", v.selection() == caretRange(0, 3)); + ensure_equals("and the others", rangesSaid(v.otherSelections()), rangesSaid(before)); + v.redo(); + ensure_equals("forward", v.text(), std::string("oneab\ntwoab\nab\n")); + ensure_equals("each after it again", rangesSaid(v.otherSelections()), rangesSaid(after)); + } + + template<> template<> + void altextview_object::test<64>() + { + set_test_name("Backspace and Delete at every caret: a run of them one step apart from the typing; what is selected taken whole"); + ALTextView& v = make("abc\nabc\nabc"); + v.setSelections(caretRange(0, 3), { caretRange(1, 3), caretRange(2, 3) }); + type("x"); + key(KEY_BACKSPACE); + key(KEY_BACKSPACE); + ensure_equals("two taken at each", v.text(), std::string("ab\nab\nab")); + v.undo(); + ensure_equals("the run of them one step", v.text(), std::string("abcx\nabcx\nabcx")); + v.undo(); + ensure_equals("the typing another", v.text(), std::string("abc\nabc\nabc")); + + v.setSelections(caretRange(0, 0), { caretRange(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 1)) }); + key(KEY_DELETE); + ensure_equals("one forward at each caret, the selection whole", v.text(), std::string("bc\nac\na")); + ensure_equals("each where it erased", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(1, 1), caretRange(2, 1) })); + } + + template<> template<> + void altextview_object::test<65>() + { + set_test_name("Return and Tab at every caret: each line broken and indented as it was; a tab at each caret, the lines of each selection a level in, and out with Shift-Tab"); + ALTextView& v = make(" one\n two"); + v.setSelections(caretRange(0, 5), { caretRange(1, 5) }); + key(KEY_RETURN); + ensure_equals("each line broken, the new one as far in", v.text(), std::string(" one\n \n two\n ")); + ensure("the main caret on its new line", v.selection() == caretRange(1, 2)); + ensure_equals("the other on its", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(3, 2) })); + + v.setText("a\nb\nc"); + v.setSelections(caretRange(0, 1), { caretRange(2, 0) }); + key(KEY_TAB); + ensure_equals("a tab to the next stop at each caret", v.text(), std::string("a \nb\n c")); + v.setText("a\nb\nc"); + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(1, 1)), { caretRange(2, 1) }); + key(KEY_TAB); + ensure_equals("the selection's lines in, a tab at the caret", v.text(), std::string(" a\n b\nc ")); + ensure("the selection with its lines", v.selection() == ALTextRange(ALTextPos(0, 4), ALTextPos(1, 5))); + key(KEY_TAB, MASK_SHIFT); + ensure_equals("out again, every selection's lines", v.text(), std::string("a\nb\nc ")); + v.undo(); + v.undo(); + ensure_equals("each a step", v.text(), std::string("a\nb\nc")); + } + + template<> template<> + void altextview_object::test<66>() + { + set_test_name("the caret's motions at every caret: up and down each keeping its own column, Home, End and the arrows, a selection extended at each and collapsed; those a motion brings together one"); + ALTextView& v = make("hello world\nhi\nhello world\nx\nhello world"); + v.setSelections(caretRange(0, 8), { caretRange(2, 8) }); + key(KEY_DOWN); + ensure("the main caret down to the short line's end", v.selection() == caretRange(1, 2)); + ensure_equals("the other to its", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(3, 1) })); + key(KEY_DOWN); + ensure("each back in its own column", v.selection() == caretRange(2, 8)); + ensure_equals("the other too", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(4, 8) })); + key(KEY_HOME); + key(KEY_RIGHT, MASK_SHIFT); + key(KEY_RIGHT, MASK_SHIFT); + ensure("extended at the main", v.selection() == ALTextRange(ALTextPos(2, 0), ALTextPos(2, 2))); + ensure_equals("and at the other", rangesSaid(v.otherSelections()), rangesSaid({ ALTextRange(ALTextPos(4, 0), ALTextPos(4, 2)) })); + key(KEY_LEFT); + ensure("collapsed to its start", v.selection() == caretRange(2, 0)); + ensure_equals("both", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(4, 0) })); + key(KEY_END); + ensure_equals("to each line's end", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(4, 11) })); + v.perform(ALEditorCommand::MoveWordLeft); + ensure("a word back at each", v.selection() == caretRange(2, 6)); + v.perform(ALEditorCommand::MoveDocStart); + ensure("all at the start, one", v.selection() == caretRange(0, 0) && !v.hasOtherSelections()); + } + + template<> template<> + void altextview_object::test<67>() + { + set_test_name("the commands over lines at every caret: duplicated, moved, deleted, joined and commented once for each line, every caret going with its line"); + ALTextView& v = make("a\nb\nc\nd\ne"); + v.setSelections(caretRange(0, 0), { caretRange(0, 1), caretRange(3, 0) }); + v.perform(ALEditorCommand::DuplicateLine); + ensure_equals("each line once", v.text(), std::string("a\na\nb\nc\nd\nd\ne")); + ensure_equals("each caret on its copy", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(1, 0), caretRange(1, 1), caretRange(5, 0) })); + v.perform(ALEditorCommand::MoveLineDown); + ensure_equals("each copy down past the next", v.text(), std::string("a\nb\na\nc\nd\ne\nd")); + v.undo(); + v.undo(); + ensure_equals("each a step", v.text(), std::string("a\nb\nc\nd\ne")); + v.setSelections(caretRange(0, 0), { caretRange(3, 0) }); + v.perform(ALEditorCommand::DeleteLine); + ensure_equals("each caret's line gone", v.text(), std::string("b\nc\ne")); + ensure_equals("each on the line that took its place", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(0, 0), caretRange(2, 0) })); + v.perform(ALEditorCommand::JoinLines); + ensure_equals("each caret's line joined to the next", v.text(), std::string("b c\ne")); + + v.setSyntax("lsl"); + v.setText("a;\n b;\nc;"); + v.setSelections(caretRange(0, 1), { caretRange(2, 2) }); + v.perform(ALEditorCommand::ToggleComment); + ensure_equals("in at each caret's line", v.text(), std::string("// a;\n b;\n// c;")); + v.perform(ALEditorCommand::ToggleComment); + ensure_equals("and out", v.text(), std::string("a;\n b;\nc;")); + ensure_equals("each caret where it was in its text", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(0, 1), caretRange(2, 2) })); + } + + template<> template<> + void altextview_object::test<68>() + { + set_test_name("copy, cut and paste at every selection: copied one to a line, and pasted one to each where there is a line for each, else the whole at each"); + LLClipboard& clipboard = LLClipboard::instance(); + ALTextView& v = make("one two\nthree four"); + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 3)), { ALTextRange(ALTextPos(1, 0), ALTextPos(1, 5)) }); + v.copy(); + std::string clip; + ensure("copied", clipboard.pasteFromClipboard(clip)); + ensure_equals("one to a line, in order", clip, std::string("one\nthree")); + v.setSelections(caretRange(0, 7), { caretRange(1, 10) }); + v.paste(); + ensure_equals("a line at each", v.text(), std::string("one twoone\nthree fourthree")); + ensure_equals("each caret after its line", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(0, 10), caretRange(1, 15) })); + + v.setText("x\ny\nz"); + v.setSelections(caretRange(0, 1), { caretRange(1, 1), caretRange(2, 1) }); + v.paste(); + ensure_equals("two lines for three carets: the whole at each", v.text(), std::string("xone\nthree\nyone\nthree\nzone\nthree")); + + v.setText("a1 a2 a3"); + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 2)), { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 8)) }); + v.cut(); + ensure_equals("each cut", v.text(), std::string(" a2 ")); + ensure("and copied", clipboard.pasteFromClipboard(clip) && clip == "a1\na3"); + v.undo(); + ensure_equals("one step", v.text(), std::string("a1 a2 a3")); + } + + template<> template<> + void altextview_object::test<69>() + { + set_test_name("a command that acts at one place acts at the main caret and lets the others go; select all takes them in"); + ALTextView& v = make("one\ntwo\nthree"); + v.setSelections(caretRange(1, 0), { caretRange(0, 0), caretRange(2, 0) }); + v.perform(ALEditorCommand::NextMisspelling); + ensure("the others let go", !v.hasOtherSelections()); + ensure("the main one where it was", v.selection() == caretRange(1, 0)); + v.setSelections(caretRange(1, 0), { caretRange(0, 0), caretRange(2, 0) }); + v.perform(ALEditorCommand::Fold); + ensure("folding keeps them", v.otherSelections().size() == 2); + v.selectAll(); + ensure("select all takes them in", !v.hasOtherSelections() && v.selection().normalised() == ALTextRange(ALTextPos(0, 0), ALTextPos(2, 5))); + } + + template<> template<> + void altextview_object::test<70>() + { + set_test_name("Alt-click adds a caret or takes the one there away, any other click is one caret; Escape is one caret before it lets the selection go; Control-Alt with up and down adds carets, each keeping its column"); + ALTextView& v = make("hello world\nhi\nhello world\nx\nhello world"); + const auto click = [&](S32 line, S32 column, MASK mask) { + S32 x = 0, y = 0; + pointOf(line, column, x, y); + v.handleMouseDown(x, y, mask); + v.handleMouseUp(x, y, mask); + }; + click(0, 3, MASK_NONE); + click(2, 3, MASK_ALT); + ensure("the new caret the main one", v.selection() == caretRange(2, 3)); + ensure_equals("the first beside it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 3) })); + click(2, 3, MASK_ALT); + ensure("taken away again, the one before it main", v.selection() == caretRange(0, 3) && !v.hasOtherSelections()); + click(0, 3, MASK_ALT); + ensure("the last is not taken away", v.selection() == caretRange(0, 3)); + click(4, 5, MASK_ALT); + click(1, 1, MASK_NONE); + ensure("a plain click: one caret", v.selection() == caretRange(1, 1) && !v.hasOtherSelections()); + + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 5)), { caretRange(2, 0) }); + key(KEY_ESCAPE); + ensure("Escape: one caret, its selection kept", !v.hasOtherSelections() && v.hasSelection()); + key(KEY_ESCAPE); + ensure("and then the selection let go", !v.hasSelection()); + + v.setCaret(ALTextPos(0, 8)); + key(KEY_DOWN, MASK_CONTROL | MASK_ALT); + ensure("a caret below, at the short line's end, the main one", v.selection() == caretRange(1, 2)); + key(KEY_DOWN, MASK_CONTROL | MASK_ALT); + ensure("and below that, back in its column", v.selection() == caretRange(2, 8)); + ensure_equals("each kept", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 8), caretRange(1, 2) })); + type("!"); + ensure_equals("typed at each", v.text(), std::string("hello wo!rld\nhi!\nhello wo!rld\nx\nhello world")); + v.setCaret(ALTextPos(4, 2)); + v.singleSelection(); + ensure("nothing below the last line", !v.perform(ALEditorCommand::AddCaretBelow)); + key(KEY_UP, MASK_CONTROL | MASK_ALT); + ensure("one above it", v.selection() == caretRange(3, 1) && v.otherSelections().size() == 1); + v.setReadOnly(true); + ensure("none in a read-only text", !v.perform(ALEditorCommand::AddCaretAbove) && !v.canPerform(ALEditorCommand::AddCaretAbove)); + } + + template<> template<> + void altextview_object::test<71>() + { + set_test_name("Alt-Return in the find bar selects every match, each a selection, the current one the main one, the keyboard back on the text"); + ALTextView& v = make("cat dog cat bird cat"); + v.showFind(false); + v.findBar()->setQuery("cat"); + ensure("three found", v.findMatches().size() == 3); + ensure("taken", v.findBar()->handleKeyHere(KEY_RETURN, MASK_ALT)); + ensure_equals("each a selection", ll_test::TextViewProbe::selections(v).size(), static_cast(3)); + ensure("the keyboard on the text", v.keyboardOnText()); + type("cow"); + ensure_equals("typed over each", v.text(), std::string("cow dog cow bird cow")); + } + + static ALTextRange spanRange(S32 line, S32 anchor, S32 caret) + { + return ALTextRange(ALTextPos(line, anchor), ALTextPos(line, caret)); + } + + template<> template<> + void altextview_object::test<72>() + { + set_test_name("Shift-Alt-drag selects a column from the anchor's x to the mouse's on each row, a tab counted as drawn and a short row a caret at its end, the mouse's row the main one; typing goes in at each, as one step"); + ALTextView& v = make("abcdefgh\nab\n\tcdefgh\nabcdefgh"); + S32 x, y; + pointOf(0, 3, x, y); + v.handleMouseDown(x, y, MASK_NONE); + v.handleMouseUp(x, y, MASK_NONE); + // Six columns in, on the line whose tab is four wide: after "cd". + S32 x6, y6, x_line2, y_line2; + pointOf(0, 6, x6, y6); + pointOf(2, 0, x_line2, y_line2); + ensure("pressed", v.handleMouseDown(x6, y_line2, MASK_SHIFT | MASK_ALT)); + ensure_equals("from column three to six on each row", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 6), caretRange(1, 2), spanRange(2, 1, 3) })); + ensure("the mouse's row the main one", v.selection() == spanRange(2, 1, 3)); + // Dragged back over the short row, to column one. + S32 x1, y1, x_line1, y_line1; + pointOf(0, 1, x1, y1); + pointOf(1, 0, x_line1, y_line1); + ensure("dragged", v.handleHover(x1, y_line1, MASK_SHIFT | MASK_ALT)); + ensure_equals("each from three back to one", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 1), spanRange(1, 2, 1) })); + v.handleMouseUp(x1, y_line1, MASK_SHIFT | MASK_ALT); + ensure("capture released", !v.hasMouseCapture()); + type("X"); + ensure_equals("typed at each", v.text(), std::string("aXdefgh\naX\n\tcdefgh\nabcdefgh")); + v.undo(); + ensure_equals("one step", v.text(), std::string("abcdefgh\nab\n\tcdefgh\nabcdefgh")); + + pointOf(3, 2, x, y); + v.handleMouseDown(x, y, MASK_NONE); + v.handleMouseUp(x, y, MASK_NONE); + ensure("a plain click is one caret again", !v.hasOtherSelections()); + v.setReadOnly(true); + v.handleMouseDown(x6, y_line2, MASK_SHIFT | MASK_ALT); + v.handleMouseUp(x6, y_line2, MASK_SHIFT | MASK_ALT); + ensure("no column in a read-only text", !v.hasOtherSelections()); + } + + template<> template<> + void altextview_object::test<73>() + { + set_test_name("Control-Shift-Alt with an arrow grows a column a column or a row at a time from the selection there is, past a short row and over a tab and a wide character as drawn; a row further than the text is refused"); + ALTextView& v = make("abcdefgh\nab\n\tcdefgh\nabcdefgh"); + const MASK column = MASK_CONTROL | MASK_SHIFT | MASK_ALT; + v.setCaret(ALTextPos(0, 3)); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + ensure("three columns on its row", v.selection() == spanRange(0, 3, 6) && !v.hasOtherSelections()); + key(KEY_DOWN, column); + key(KEY_DOWN, column); + ensure_equals("and two rows down", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 6), caretRange(1, 2), spanRange(2, 1, 3) })); + type("X"); + ensure_equals("typed at each", v.text(), std::string("abcXgh\nabX\n\tXefgh\nabcdefgh")); + v.undo(); + ensure_equals("one step", v.text(), std::string("abcdefgh\nab\n\tcdefgh\nabcdefgh")); + + // One caret first: the undone column's selections are back, and a + // caret put among them would be taken into the one it touches. + v.singleSelection(); + v.setCaret(ALTextPos(0, 3)); + key(KEY_DOWN, column); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + key(KEY_LEFT, column); + ensure_equals("a row, then two columns", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 5), caretRange(1, 2) })); + key(KEY_UP, column); + key(KEY_LEFT, column); + key(KEY_LEFT, column); + key(KEY_LEFT, column); + ensure("back to its row and past where it began", v.selection() == spanRange(0, 3, 2) && !v.hasOtherSelections()); + ensure("no row above the first", !v.perform(ALEditorCommand::ColumnSelectUp)); + + // From a selection that is not a column: its anchor and caret the + // corners. Column one is nearer the tab's start than its end. + v.setSelection(spanRange(3, 1, 4)); + key(KEY_UP, column); + ensure_equals("the tab, as drawn, above three columns", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(2, 0, 1), spanRange(3, 1, 4) })); + ensure("the moving corner's row the main one", v.selection() == spanRange(2, 0, 1)); + + // Wide characters count as wide as the font draws them: the two of + // them selected, the row below gets as many columns as they are + // drawn across, to the nearest -- not one a character, nor a byte. + v.setText("\xe4\xb8\xad\xe6\x96\x87xy\nabcdefgh"); + const F32 width = v.layout().columnWidth(); + const F32 columns = v.layout().xOf(0, 6) / width; + if (llabs(columns - std::floor(columns) - 0.5f) > 0.1f) + { + v.singleSelection(); + v.setSelection(spanRange(0, 0, 6)); + key(KEY_DOWN, column); + ensure_equals("as many columns as they are drawn across", rangesSaid({ v.selection() }), + rangesSaid({ spanRange(1, 0, ll_round(columns)) })); + } + + v.setReadOnly(true); + ensure("none in a read-only text", !v.canPerform(ALEditorCommand::ColumnSelectDown) && !v.perform(ALEditorCommand::ColumnSelectDown)); + } + + template<> template<> + void altextview_object::test<74>() + { + set_test_name("with several carets a composition stands at the main one only, the others moving aside for it and back, and what it commits is typed at each"); + ALTextView& v = make("abcd\nabcd"); + v.setSelections(caretRange(0, 1), { caretRange(0, 3), caretRange(1, 1) }); + LLPreeditor& ime = v.preeditor(); + ime.updatePreedit("XY", { 2 }, { false }, 2); + ensure_equals("at the main caret only", v.text(), std::string("aXYbcd\nabcd")); + ensure_equals("the others moved aside for it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 5), caretRange(1, 1) })); + ime.resetPreedit(); + ensure_equals("gone", v.text(), std::string("abcd\nabcd")); + ensure_equals("and they back", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 3), caretRange(1, 1) })); + ensure("the main one where it began", v.selection() == caretRange(0, 1)); + ensure("none of it an edit", !v.isDirty()); + // What the input method commits comes as typed characters. + type("Z"); + ensure_equals("committed: typed at each", v.text(), std::string("aZbcZd\naZbcd")); + } + + template<> template<> + void altextview_object::test<75>() + { + set_test_name("with several selections the find bar is seeded from the main one"); + ALTextView& v = make("cat dog\nbird cat"); + v.setSelections(spanRange(0, 4, 7), { spanRange(1, 0, 4) }); + v.showFind(false); + ensure_equals("the main selection's text", v.findBar()->query(), std::string("dog")); + v.hideFind(); + v.setSelections(caretRange(1, 6), { spanRange(0, 4, 7) }); + v.showFind(false); + ensure_equals("the word at the main caret, not another's selection", v.findBar()->query(), std::string("cat")); + } + + template<> template<> + void altextview_object::test<77>() + { + set_test_name("Tab with several: a caret on a line a selection indents goes in with the line; one elsewhere puts its tab in"); + ALTextView& v = make("abcdefgh\nxy"); + v.setSelections(spanRange(0, 3, 6), { caretRange(0, 1), caretRange(1, 1) }); + v.perform(ALEditorCommand::Indent); + ensure_equals("the selection's line indented once", v.document().line(0), std::string(" abcdefgh")); + ensure_equals("the selection over what it held", v.selectedText(), std::string("def")); + const std::vector& others = v.otherSelections(); + ensure("the caret on its line moved with it", std::ranges::find(others, caretRange(0, 5)) != others.end()); + const std::string& second = v.document().line(1); + ensure("a tab put in at the caret elsewhere: " + second, second.size() > 2 && second.front() == 'x' && second.back() == 'y'); + } + + template<> template<> + void altextview_object::test<76>() + { + set_test_name("the find bar's next and previous let the other carets go, as F3 does"); + ALTextView& v = make("cat dog\nbird cat"); + v.setSelections(spanRange(0, 0, 3), { caretRange(1, 2) }); + v.showFind(false); + ensure("found from the bar", v.findNext(true)); + ensure("the other caret let go", !v.hasOtherSelections()); + ensure("at the next match", v.selection().normalised() == spanRange(1, 5, 8)); + v.setSelections(spanRange(1, 5, 8), { caretRange(0, 5) }); + ensure("found back from the bar", v.findNext(false)); + ensure("the other caret let go going back", !v.hasOtherSelections()); + ensure("at the match before", v.selection().normalised() == spanRange(0, 0, 3)); + } } diff --git a/indra/llui/tests/alvimkeymap_test.cpp b/indra/llui/tests/alvimkeymap_test.cpp index d232f72974..4d30eda6cd 100644 --- a/indra/llui/tests/alvimkeymap_test.cpp +++ b/indra/llui/tests/alvimkeymap_test.cpp @@ -2818,4 +2818,42 @@ namespace tut ensure_equals("gone to", e.caret().line, 44); ensure("and below them there: " + std::to_string(below) + " of " + std::to_string(row_h), below >= 2 * row_h); } + + template<> template<> + void alvimkeymap_object::test<95>() + { + set_test_name("vim has one caret: what insert mode typed at several stays, Escape leaves the main caret alone, stepped back onto it, and a count types again there only"); + ALCodeEditor& e = make("one\ntwo\nthree"); + e.setSelections(ALTextRange(ALTextPos(2, 0), ALTextPos(2, 0)), { ALTextRange(ALTextPos(0, 0), ALTextPos(0, 0)), ALTextRange(ALTextPos(1, 0), ALTextPos(1, 0)) }); + keys("iab"); + ensure_equals("typed at each", e.text(), std::string("abone\nabtwo\nabthree")); + ensure("still several while inserting", e.hasOtherSelections()); + keys(""); + ensure("one caret in normal mode", !e.hasOtherSelections()); + ensure("the main one, on the last character it typed", e.caret() == ALTextPos(2, 1)); + e.setSelections(ALTextRange(ALTextPos(2, 0), ALTextPos(2, 0)), { ALTextRange(ALTextPos(0, 0), ALTextPos(0, 0)), ALTextRange(ALTextPos(1, 0), ALTextPos(1, 0)) }); + keys("3iX"); + ensure_equals("once at each, the count's again at the main one only", e.text(), std::string("Xabone\nXabtwo\nXXXabthree")); + ensure("one caret", !e.hasOtherSelections()); + } + + template<> template<> + void alvimkeymap_object::test<96>() + { + set_test_name("with several carets: a key held for a mapping goes in at each when let go, Replace overtypes at each, and an undo leaves normal mode one caret"); + ALCodeEditor& e = make("one\ntwo"); + const auto caret = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + ex("inoremap jk "); + e.setSelections(caret(1, 0), { caret(0, 0) }); + keys("ijx"); + ensure_equals("the held j at each, then the x", e.text(), std::string("jxone\njxtwo")); + keys(""); + e.setSelections(caret(1, 0), { caret(0, 0) }); + keys("RQ"); + ensure_equals("overtyped at each", e.text(), std::string("Qxone\nQxtwo")); + keys(""); + keys("u"); + ensure_equals("undone", e.text(), std::string("jxone\njxtwo")); + ensure("one caret in normal mode after it", !e.hasOtherSelections()); + } } diff --git a/indra/llui/tests/alxuifindings_test.cpp b/indra/llui/tests/alxuifindings_test.cpp index 9eea05e857..cfd463fc24 100644 --- a/indra/llui/tests/alxuifindings_test.cpp +++ b/indra/llui/tests/alxuifindings_test.cpp @@ -96,7 +96,7 @@ namespace tut // A file with nothing to say is still a file that was looked at, and // that is not the same answer as one nobody has checked. ensure("one nobody checked", !store.checked("three.xml")); - store.replace("three.xml", {}); + store.replace("three.xml", std::vector()); ensure("one that was", store.checked("three.xml")); ensure_equals("with nothing in it", store.countIn("three.xml"), 0); ensure_equals("and nothing added to the whole", store.size(), 4u); @@ -241,7 +241,7 @@ namespace tut // Let go of and checked again, it is a file checked anew, at the end. store.forget("one.xml"); - store.replace("one.xml", {}); + store.replace("one.xml", std::vector()); ensure("at the end", store.files() == std::vector({ "two.xml", "three.xml", "one.xml" })); ensure_equals("counted once", store.files().size(), size_t(3)); } diff --git a/indra/llwindow/llwindowsdl.cpp b/indra/llwindow/llwindowsdl.cpp index 281e142b17..4076b97e5c 100644 --- a/indra/llwindow/llwindowsdl.cpp +++ b/indra/llwindow/llwindowsdl.cpp @@ -1361,9 +1361,47 @@ bool LLWindowSDL::pasteTextFromClipboard(std::string &dst) return false; } +#if LL_LINUX +namespace +{ + // What was last copied, held here rather than by SDL. SDL frees the text + // it was given when it decides another client has the clipboard, while + // the Wayland source or X11 owner it handed that text to can go on + // serving it (SDL issues 16037 and 16245), and another application + // pasted whatever had since been allocated there. The registry's sdl3 + // patches the wrong decisions known on 3.4.18 + // (clipboard-keep-own-selection.patch); this stands for any left. SDL's + // cleanup does nothing, so whatever still serves the text reads this. + std::string sClipboardText; + + // Every name the X11 and Wayland backends give text by: theirs when + // SDL_SetClipboardText chooses, so that SDL_GetClipboardText finds them. + const char* const CLIPBOARD_TEXT_TYPES[] = { + "text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "TEXT", "STRING" + }; + + const void* SDLCALL clipboardText(void*, const char*, size_t* size) + { + *size = sClipboardText.size(); + return sClipboardText.data(); + } + + void SDLCALL keepClipboardText(void*) {} +} +#endif + bool LLWindowSDL::copyTextToClipboard(const std::string& text) { +#if LL_LINUX + if (text.empty()) + { + return SDL_ClearClipboardData(); + } + sClipboardText = text; + return SDL_SetClipboardData(clipboardText, keepClipboardText, nullptr, CLIPBOARD_TEXT_TYPES, std::size(CLIPBOARD_TEXT_TYPES)); +#else return SDL_SetClipboardText(text.c_str()); +#endif } bool LLWindowSDL::isPrimaryTextAvailable() diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index c78d5314fb..438a70ab1a 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -230,6 +230,8 @@ if(AL_BUILD_VIEWER) alscriptstudiosaving.cpp alscriptstudioviewer.cpp alscriptstudiovim.cpp + alfollowednotecard.cpp + alscriptstudiosnippetnotecard.cpp alscriptstudiovimrc.cpp alscriptstudioweighing.cpp alscriptstudiowords.cpp @@ -1074,6 +1076,8 @@ if(AL_BUILD_VIEWER) alrecoverykeeper.h alscriptstudiosaving.h alscriptstudiovim.h + alfollowednotecard.h + alscriptstudiosnippetnotecard.h alscriptstudiovimrc.h alscriptstudioweighing.h alscriptstudiowords.h diff --git a/indra/newview/alfloaterscriptpreferences.cpp b/indra/newview/alfloaterscriptpreferences.cpp index a64678d237..17b8ca1be8 100644 --- a/indra/newview/alfloaterscriptpreferences.cpp +++ b/indra/newview/alfloaterscriptpreferences.cpp @@ -32,6 +32,7 @@ #include "alcodeeditor.h" #include "alscriptstudio.h" #include "alscriptanalysis.h" +#include "alscriptstudiosnippetnotecard.h" #include "alscriptstudiovimrc.h" #include "alscriptstudiowords.h" #include "alfontfield.h" @@ -65,7 +66,9 @@ #include "llviewerassettype.h" #include +#include #include +#include namespace { @@ -103,6 +106,21 @@ namespace // How long after the last keystroke an edited snippet is written. const F32 SNIPPET_SETTLE = 0.5f; + // The notecard of the agent's own that a drop carries, a link as what + // it links to -- the notecard's asset is what changes as it is edited; + // null where it carries anything else. The kind first: a drag of a + // person or a group carries an id, not an item. + const LLViewerInventoryItem* droppedNotecard(EDragAndDropType cargo_type, void* cargo_data) + { + if (cargo_type != DAD_NOTECARD || !cargo_data) + { + return nullptr; + } + const LLInventoryItem* item = static_cast(cargo_data); + const LLViewerInventoryItem* held = gInventory.getItem(item->getLinkedUUID()); + return held && held->getType() == LLAssetType::AT_NOTECARD ? held : nullptr; + } + const S32 SWATCH_ROW = 24; const S32 SWATCH_WIDTH = 48; const S32 SWATCH_HEIGHT = 20; @@ -150,6 +168,14 @@ bool ALFloaterScriptPreferences::postBuild() [](LLUICtrl*, const LLSD&) { ALScriptStudioVimrc::instance().useNotecard(LLUUID::null); }); mVimrcChanged = ALScriptStudioVimrc::instance().onChanged([this]() { refreshVimrc(); }); refreshVimrc(); + mSnippetNotecard = getChild("snippet_notecard"); + getChild("snippet_notecard_clear")->setCommitCallback( + [](LLUICtrl*, const LLSD&) { ALScriptStudioSnippetNotecard::instance().useNotecard(LLUUID::null); }); + mSnippetNotecardChanged = ALScriptStudioSnippetNotecard::instance().onChanged([this]() { + refreshSnippetNotecard(); + fillSnippets(false); + }); + refreshSnippetNotecard(); getChild("scripting_settings")->setCommitCallback([](LLUICtrl*, const LLSD&) { LLFloaterReg::showInstance("scripting_settings"); }); getChild("snippet_xml")->setCommitCallback([this](LLUICtrl*, const LLSD&) { flushSnippets(); @@ -459,8 +485,9 @@ void ALFloaterScriptPreferences::remember() mWasSettings[setting] = control->getValue(); } } - // The account's, as the notecard is. - mWasVimrc = ALScriptStudioVimrc::instance().notecard().asString(); + // The account's, as the notecards are. + mWasVimrc = ALScriptStudioVimrc::instance().notecard().asString(); + mWasSnippetNotecard = ALScriptStudioSnippetNotecard::instance().notecard().asString(); } void ALFloaterScriptPreferences::revert() @@ -489,6 +516,10 @@ void ALFloaterScriptPreferences::revert() { ALScriptStudioVimrc::instance().useNotecard(LLUUID(mWasVimrc)); } + if (ALScriptStudioSnippetNotecard::instance().notecard().asString() != mWasSnippetNotecard) + { + ALScriptStudioSnippetNotecard::instance().useNotecard(LLUUID(mWasSnippetNotecard)); + } // The snippets as they were, where they were changed here. for (bool lua : { false, true }) { @@ -504,7 +535,8 @@ void ALFloaterScriptPreferences::revert() bool ALFloaterScriptPreferences::changed() const { - if (mSnippetsUnsaved || ALScriptStudioVimrc::instance().notecard().asString() != mWasVimrc) + if (mSnippetsUnsaved || ALScriptStudioVimrc::instance().notecard().asString() != mWasVimrc || + ALScriptStudioSnippetNotecard::instance().notecard().asString() != mWasSnippetNotecard) { return true; } @@ -871,50 +903,56 @@ void ALFloaterScriptPreferences::refreshVimrc() where->setText(!vimrc.error().empty() ? vimrc.error() : getString(notecard ? "VimrcFromNotecard" : "VimrcFromFile", args)); } +void ALFloaterScriptPreferences::refreshSnippetNotecard() +{ + const ALScriptStudioSnippetNotecard& followed = ALScriptStudioSnippetNotecard::instance(); + const bool notecard = followed.notecard().notNull(); + const std::string name = followed.notecardName(); + // Empty with none: the box's label asks for one. + mSnippetNotecard->setText(!notecard ? std::string() : name.empty() ? getString("SnippetNotecardUnknown") : name); + mSnippetNotecard->setToolTip(!followed.error().empty() ? followed.error() : getString("SnippetNotecardTip")); + getChildView("snippet_notecard_clear")->setEnabled(notecard); +} + bool ALFloaterScriptPreferences::handleDragAndDrop(S32 x, S32 y, MASK mask, bool drop, EDragAndDropType cargo_type, void* cargo_data, EAcceptance* accept, std::string& tooltip_msg) { - S32 local_x = 0; - S32 local_y = 0; - if (mVimrcNotecard && mVimrcNotecard->isInVisibleChain() && localPointToOtherView(x, y, &local_x, &local_y, mVimrcNotecard) && - mVimrcNotecard->pointInView(local_x, local_y)) - { - // A notecard of the agent's own, a link as what it links to: the - // notecard's asset is what changes as it is edited. - const LLInventoryItem* item = static_cast(cargo_data); - const LLViewerInventoryItem* held = item ? gInventory.getItem(item->getLinkedUUID()) : nullptr; - if (cargo_type != DAD_NOTECARD || !held || held->getType() != LLAssetType::AT_NOTECARD) + // Which box it is over, and what a notecard dropped there does; each + // takes a notecard of the agent's own alone. + const auto over = [this, x, y](LLView* view) { + S32 local_x = 0; + S32 local_y = 0; + return view && view->isInVisibleChain() && localPointToOtherView(x, y, &local_x, &local_y, view) && view->pointInView(local_x, local_y); + }; + const auto take = [&](const char* only, const std::function& use) { + const LLViewerInventoryItem* held = droppedNotecard(cargo_type, cargo_data); + if (!held) { *accept = ACCEPT_NO; - tooltip_msg = getString("VimrcOnlyNotecards"); + tooltip_msg = getString(only); return true; } *accept = ACCEPT_YES_SINGLE; if (drop) { - ALScriptStudioVimrc::instance().useNotecard(held->getUUID()); + use(*held); } return true; + }; + if (over(mVimrcNotecard)) + { + return take("VimrcOnlyNotecards", [](const LLViewerInventoryItem& held) { ALScriptStudioVimrc::instance().useNotecard(held.getUUID()); }); } - if (mSnippetList && mSnippetList->isInVisibleChain() && localPointToOtherView(x, y, &local_x, &local_y, mSnippetList) && - mSnippetList->pointInView(local_x, local_y)) + if (over(mSnippetNotecard)) { - // A notecard of the agent's own, whose snippets come in beside - // theirs. - const LLInventoryItem* item = static_cast(cargo_data); - const LLViewerInventoryItem* held = item ? gInventory.getItem(item->getLinkedUUID()) : nullptr; - if (cargo_type != DAD_NOTECARD || !held || held->getType() != LLAssetType::AT_NOTECARD) - { - *accept = ACCEPT_NO; - tooltip_msg = getString("SnippetsOnlyNotecards"); - return true; - } - *accept = ACCEPT_YES_SINGLE; - if (drop) - { - addSnippetsFrom(*held); - } - return true; + // Followed: its snippets offered as it stands. + return take("SnippetsFollowOnlyNotecards", + [](const LLViewerInventoryItem& held) { ALScriptStudioSnippetNotecard::instance().useNotecard(held.getUUID()); }); + } + if (over(mSnippetList)) + { + // Its snippets come in beside the scripter's own. + return take("SnippetsOnlyNotecards", [this](const LLViewerInventoryItem& held) { addSnippetsFrom(held); }); } return LLFloater::handleDragAndDrop(x, y, mask, drop, cargo_type, cargo_data, accept, tooltip_msg); } @@ -1093,7 +1131,7 @@ bool ALFloaterScriptPreferences::snippetLua() const S32 ALFloaterScriptPreferences::chosenOwnSnippet() const { // A row's value says whose it is and where, as the list compares - // values by their words: "own:2", "builtin:5". + // values by their words: "own:2", "followed:0", "builtin:5". const LLScrollListItem* item = mSnippetList->getFirstSelected(); const std::string value = item ? item->getValue().asString() : std::string(); if (value.compare(0, 4, "own:") != 0) @@ -1126,21 +1164,34 @@ void ALFloaterScriptPreferences::fillSnippets(bool reread) mSnippetsLua = lua; mOwnSnippets = ALScriptSnippets::own(lua); } - const LLSD chosen = mSnippetList->getFirstSelected() ? mSnippetList->getFirstSelected()->getValue() : LLSD(); - const S32 scrolled = mSnippetList->getScrollPos(); + const LLScrollListItem* first = mSnippetList->getFirstSelected(); + const LLSD chosen = first ? first->getValue() : LLSD(); + const S32 scrolled = mSnippetList->getScrollPos(); + // One of the notecard's is found again by its name: the notecard may + // have changed under it, and its place with it. + std::string chosen_followed; + if (first && chosen.asString().compare(0, 9, "followed:") == 0 && first->getColumn(0)) + { + chosen_followed = first->getColumn(0)->getValue().asString(); + } mSnippetList->deleteAllItems(); const LLUIColor& theirs = ALSurface::quiet(); - const auto add = [this, &theirs](const ALScriptSnippets::Snippet& one, bool own, S32 index) { + LLStringUtil::format_map_t args; + args["[NAME]"] = ALScriptStudioSnippetNotecard::instance().notecardName(); + const std::string followed = getString("SnippetFollowed", args); + const auto add = [this, &theirs, &followed](const ALScriptSnippets::Snippet& one, const char* whose, S32 index) { + const bool own = std::string_view(whose) == "own"; LLSD row; - row["value"] = (own ? "own:" : "builtin:") + std::to_string(index); + row["value"] = std::string(whose) + ":" + std::to_string(index); row["columns"][0]["column"] = "name"; row["columns"][0]["value"] = one.name.empty() ? getString("SnippetUnnamed") : one.name; - row["columns"][0]["tool_tip"] = own ? one.detail : getString("SnippetBuiltin"); + row["columns"][0]["tool_tip"] = own ? one.detail : one.followed ? followed : getString("SnippetBuiltin"); row["columns"][1]["column"] = "prefix"; row["columns"][1]["value"] = one.prefix; if (!own) { - // The viewer's, quieter than the scripter's own. + // The viewer's and the notecard's, quieter than the scripter's + // own. row["columns"][0]["color"] = theirs.get().getValue(); row["columns"][1]["color"] = theirs.get().getValue(); } @@ -1148,17 +1199,47 @@ void ALFloaterScriptPreferences::fillSnippets(bool reread) }; for (size_t i = 0; i < mOwnSnippets.size(); ++i) { - add(mOwnSnippets[i], true, static_cast(i)); + add(mOwnSnippets[i], "own", static_cast(i)); + } + const std::vector& notecard = ALScriptSnippets::followed(lua); + for (size_t i = 0; i < notecard.size(); ++i) + { + add(notecard[i], "followed", static_cast(i)); + } + // Why the notecard's are not there, or not as it stands, where they + // would be: a row that cannot be chosen. + if (const std::string& error = ALScriptStudioSnippetNotecard::instance().error(); !error.empty()) + { + LLSD row; + row["value"] = "followed:error"; + row["enabled"] = false; + row["columns"][0]["column"] = "name"; + row["columns"][0]["value"] = error; + row["columns"][0]["tool_tip"] = error; + row["columns"][0]["color"] = LLUIColorTable::instance().getColor("ScriptErrorColor").get().getValue(); + mSnippetList->addElement(row); } S32 builtin = 0; for (const ALScriptSnippets::Snippet& one : ALScriptSnippets::all(lua)) { if (one.builtin) { - add(one, false, builtin++); + add(one, "builtin", builtin++); } } - if (chosen.isString()) + if (!chosen_followed.empty()) + { + for (const LLScrollListItem* row : mSnippetList->getAllData()) + { + if (row->getValue().asString().compare(0, 9, "followed:") == 0 && row->getColumn(0) && + row->getColumn(0)->getValue().asString() == chosen_followed) + { + mSnippetList->selectByValue(row->getValue()); + break; + } + } + } + else if (chosen.isString()) { mSnippetList->selectByValue(chosen); } @@ -1179,14 +1260,24 @@ void ALFloaterScriptPreferences::showSnippet() } else if (item) { - // One of the viewer's, by its place among them. - S32 builtin = 0; - for (const ALScriptSnippets::Snippet& one : ALScriptSnippets::all(snippetLua())) + // The followed notecard's, or the viewer's, by its place among them. + const std::string value = item->getValue().asString(); + const std::vector& notecard = ALScriptSnippets::followed(snippetLua()); + if (value.compare(0, 9, "followed:") == 0) + { + const S32 index = atoi(value.c_str() + 9); + shown = index >= 0 && index < static_cast(notecard.size()) ? ¬ecard[static_cast(index)] : nullptr; + } + else if (value.compare(0, 8, "builtin:") == 0) { - if (one.builtin && builtin++ == atoi(item->getValue().asString().c_str() + 8)) + S32 builtin = 0; + for (const ALScriptSnippets::Snippet& one : ALScriptSnippets::all(snippetLua())) { - shown = &one; - break; + if (one.builtin && builtin++ == atoi(value.c_str() + 8)) + { + shown = &one; + break; + } } } } @@ -1200,7 +1291,8 @@ void ALFloaterScriptPreferences::showSnippet() mSnippetBody->setText(body); } mSettingSnippet = false; - // The scripter's own are edited here; the viewer's are read, and copied. + // The scripter's own are edited here; the viewer's and the notecard's + // are read, and copied. const bool editable = own >= 0; mSnippetName->setEnabled(editable); mSnippetPrefix->setEnabled(editable); diff --git a/indra/newview/alfloaterscriptpreferences.h b/indra/newview/alfloaterscriptpreferences.h index 811258c7ba..2f33254dc6 100644 --- a/indra/newview/alfloaterscriptpreferences.h +++ b/indra/newview/alfloaterscriptpreferences.h @@ -66,7 +66,8 @@ class ALFloaterScriptPreferences final : public LLFloater // OK and Cancel: where something was changed, asked first whether to // keep it, put it back, or stay. bool canClose() override; - // A notecard dropped on the vimrc's box. + // A notecard dropped on the vimrc's box, the snippet list, or the box + // for the snippet notecard followed. bool handleDragAndDrop(S32 x, S32 y, MASK mask, bool drop, EDragAndDropType cargo_type, void* cargo_data, EAcceptance* accept, std::string& tooltip_msg) override; @@ -146,6 +147,9 @@ class ALFloaterScriptPreferences final : public LLFloater // The vimrc's box: the notecard's name, or none, and where the vimrc // is read from or why it could not be. void refreshVimrc(); + // The followed snippet notecard's box: its name, or none, and why its + // snippets could not be read, where they could not. + void refreshSnippetNotecard(); LLTabContainer* mTabs = nullptr; LLComboBox* mThemes = nullptr; @@ -213,6 +217,10 @@ class ALFloaterScriptPreferences final : public LLFloater // The notecard that was the vimrc when the window opened, for Cancel. std::string mWasVimrc; boost::signals2::scoped_connection mVimrcChanged; + LLLineEditor* mSnippetNotecard = nullptr; + // The snippet notecard followed when the window opened, for Cancel. + std::string mWasSnippetNotecard; + boost::signals2::scoped_connection mSnippetNotecardChanged; LLScrollListCtrl* mLintsLSL = nullptr; LLScrollListCtrl* mLintsLuau = nullptr; // The settings the enabling follows, by their own signals, so that it diff --git a/indra/newview/alfloaterscriptstudio.cpp b/indra/newview/alfloaterscriptstudio.cpp index fd80784daf..2c1c043e03 100644 --- a/indra/newview/alfloaterscriptstudio.cpp +++ b/indra/newview/alfloaterscriptstudio.cpp @@ -48,6 +48,7 @@ #include "alscriptstudiofileio.h" #include "alscriptstudioglue.h" #include "alscriptstudioplaces.h" +#include "alscriptstudiosnippetnotecard.h" #include "alscriptstudiovimrc.h" #include "alscriptworkspace.h" #include "alemptystate.h" @@ -154,13 +155,9 @@ namespace return lua ? LLSyntaxDefCache::instance().getLuaKeywords() : LLSyntaxDefCache::instance().getLSLKeywords(); } std::string definitionsVersion() override { return LLSyntaxDefCache::instance().getSyntaxID().asString(); } - std::string definitionsYaml() override - { - llifstream in(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "syntax_default", "lsl_definitions.yaml"), std::ios::binary); - std::stringstream text; - text << in.rdbuf(); - return text.str(); - } + // The region's, where it delivered them, as the keywords are; else + // those shipped. + std::string definitionsYaml() override { return LLSyntaxDefCache::instance().loadCacheFile("lsl_definitions.yaml"); } std::vector preprocessorWords() override { std::vector words; @@ -614,6 +611,7 @@ bool ALFloaterScriptStudio::postBuild() // wanted and goes when closed. Said here, since building from the // skin sets both from the file, which cannot say which this is. setIsSingleInstance(mMain); + mTabTips = { getString("TabNotecardTip"), getString("TabInventoryTip"), getString("TabObjectTip"), getString("TabReadOnlyTip") }; if (!mMain) { // No saved rect: a popped-out window is placed beside the one it @@ -1475,6 +1473,8 @@ void ALFloaterScriptStudio::draw() { ALScriptStudioVimrc::instance().check(); } + // The followed snippet notecard's, fetched again where it is saved. + ALScriptStudioSnippetNotecard::instance().check(); mSearchPane->pump(); mNavigation.pumpSettle(); refreshUndoLabels(); @@ -3295,14 +3295,14 @@ ALTabStrip::Tab ALFloaterScriptStudio::tabOf(const Doc& doc, const TabFacts& fac // does not fit, and the name it cut is the one thing you hover a // cut tab to read; saying only where the script lives answered a // question nobody had asked. - const std::string where = !doc.file.empty() ? doc.file - : doc.notecard ? getString("TabNotecardTip") - : doc.ref.inInventory() ? getString("TabInventoryTip") - : getString("TabObjectTip"); + const std::string& where = !doc.file.empty() ? doc.file + : doc.notecard ? mTabTips.notecard + : doc.ref.inInventory() ? mTabTips.inventory + : mTabTips.object; tab.toolTip = doc.name + "\n" + where; if (facts.readOnly) { - tab.toolTip += "\n" + getString("TabReadOnlyTip"); + tab.toolTip += "\n" + mTabTips.readOnly; } return tab; } @@ -3900,7 +3900,7 @@ std::vector ALFloaterScriptStudio::problemPlaces(const Doc& doc, bool const std::string definitions = getString("OriginDefinitions"); const std::string weight = getString("OriginWeight"); std::vector places; - for (const Doc::Shown& row : doc.shown) + for (const Doc::Shown& row : doc.shown()) { if (row.file.empty() && row.origin != definitions && row.origin != weight && (!migration || row.migration)) { @@ -4898,11 +4898,11 @@ std::vector ALFloaterScriptStudio::paletteScripts(std::v for (const std::unique_ptr& each : window->mDocs) { const Doc& doc = *each; - const std::string where = !doc.file.empty() ? doc.file - : doc.notecard ? getString("TabNotecardTip") - : doc.ref.inInventory() ? getString("TabInventoryTip") - : !doc.objectName.empty() ? doc.objectName - : getString("TabObjectTip"); + const std::string& where = !doc.file.empty() ? doc.file + : doc.notecard ? mTabTips.notecard + : doc.ref.inInventory() ? mTabTips.inventory + : !doc.objectName.empty() ? doc.objectName + : mTabTips.object; GoTo target; target.kind = GoTo::Kind::Tab; target.window = window->getHandle(); @@ -8130,6 +8130,14 @@ void ALFloaterScriptStudio::addEditCommands() addEditorCommand("shrink_selection", ALEditorCommand::ShrinkSelection, false); addEditorCommand("select_next_occurrence", ALEditorCommand::SelectNextOccurrence, true); addEditorCommand("change_all_occurrences", ALEditorCommand::ChangeAllOccurrences, true); + addEditorCommand("add_caret_above", ALEditorCommand::AddCaretAbove, false); + addEditorCommand("add_caret_below", ALEditorCommand::AddCaretBelow, false); + // Keys a step at a time rather than menu items: Shift-Alt-drag puts a + // column with the mouse. + addEditorCommand("column_select_left", ALEditorCommand::ColumnSelectLeft, false); + addEditorCommand("column_select_right", ALEditorCommand::ColumnSelectRight, false); + addEditorCommand("column_select_up", ALEditorCommand::ColumnSelectUp, false); + addEditorCommand("column_select_down", ALEditorCommand::ColumnSelectDown, false); mCommands.add( "convert_slua", [this]() { @@ -8331,7 +8339,7 @@ void ALFloaterScriptStudio::addGoCommands() }, [this]() { Doc* doc = active(); - return doc && doc->loaded && !doc->shown.empty(); + return doc && doc->loaded && !doc->shown().empty(); }); } mCommands.add("next_tab", [this]() { cycleTab(1); }); diff --git a/indra/newview/alfloaterscriptstudio.h b/indra/newview/alfloaterscriptstudio.h index 78e415afad..f8e2321d99 100644 --- a/indra/newview/alfloaterscriptstudio.h +++ b/indra/newview/alfloaterscriptstudio.h @@ -170,6 +170,14 @@ class ALFloaterScriptStudio final : public ALStudioFloater, public ALScriptStudi // left where it is. void openScript(const ALScriptRef& ref, const std::string& name, std::optional carried = std::nullopt, S32 line = -1, bool focus = true) override; + void holdTabs() override { ++mTabsHeld; } + void letGoOfTabs() override + { + if (mTabsHeld > 0 && --mTabsHeld == 0) + { + releaseTabs(); + } + } // The active script moved to a window of its own, its unsaved text // going with it; or to another studio window already open. False where // it could not go. @@ -890,6 +898,7 @@ class ALFloaterScriptStudio final : public ALStudioFloater, public ALScriptStudi void trailerChosen(const std::string& value) override; std::string vimBanner() const override { return mVim.banner(); } std::optional weightTarget(const Doc& doc) const override { return mWeighing.target(doc); } + ALPreprocessor::Wanted preprocessedWhy(const Doc& doc) const override { return mChecking.preprocessedWhy(doc); } // How many errors and warnings a script shows. void problemCounts(const Doc& doc, S32& errors, S32& warnings) const override; // What the Output tab asks of the window (ALScriptOutputPane::Window). @@ -991,6 +1000,16 @@ class ALFloaterScriptStudio final : public ALStudioFloater, public ALScriptStudi std::optional mToolbarFacts; std::vector mTabFacts; size_t mTabFactsActive = NONE; + // What a tab's tip says of where its script is, read from the skin + // once rather than for every tab at every refill. + struct TabTips + { + std::string notecard; + std::string inventory; + std::string object; + std::string readOnly; + }; + TabTips mTabTips; // What changed of a tab that the window shows of it, made good once // before the next frame is drawn, however often it was asked for in // between (docChanged); and made good now, all of it or one tab's. diff --git a/indra/newview/alfollowednotecard.cpp b/indra/newview/alfollowednotecard.cpp new file mode 100644 index 0000000000..8d854861e5 --- /dev/null +++ b/indra/newview/alfollowednotecard.cpp @@ -0,0 +1,205 @@ +/** + * @file alfollowednotecard.cpp + * @brief A notecard in inventory followed for its text, kept on disk while its asset is unchanged. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alfollowednotecard.h" + +#include "alscriptworkspace.h" +#include "llfile.h" +#include "llinventorymodel.h" +#include "llsdserialize.h" +#include "lltrans.h" +#include "llviewercontrol.h" +#include "llviewerinventory.h" + +ALFollowedNotecard::ALFollowedNotecard(std::string setting, std::string cache_file, std::string not_notecard) + : mSetting(std::move(setting)), mCacheFile(std::move(cache_file)), mNotNotecard(std::move(not_notecard)) +{ + if (LLControlVariable* control = gSavedPerAccountSettings.getControl(mSetting)) + { + mSettingChanged = control->getSignal()->connect([this](LLControlVariable*, const LLSD&, const LLSD&) { + mFailed.setNull(); + mMoving = true; + refresh(); + mMoving = false; + mChanged(true); + }); + } + refresh(); +} + +ALFollowedNotecard::~ALFollowedNotecard() = default; + +LLUUID ALFollowedNotecard::item() const +{ + const LLControlVariable* control = gSavedPerAccountSettings.getControl(mSetting); + return control ? LLUUID(control->getValue().asString()) : LLUUID::null; +} + +std::string ALFollowedNotecard::name() const +{ + const LLUUID followed = item(); + if (followed.isNull()) + { + return std::string(); + } + if (const LLViewerInventoryItem* held = gInventory.getItem(followed)) + { + return held->getName(); + } + return mCacheItem == followed ? mCacheName : std::string(); +} + +void ALFollowedNotecard::use(const LLUUID& followed) +{ + gSavedPerAccountSettings.setString(mSetting, followed.isNull() ? std::string() : followed.asString()); +} + +void ALFollowedNotecard::check() +{ + const LLUUID followed = item(); + if (followed.isNull()) + { + return; + } + const LLViewerInventoryItem* held = gInventory.getItem(followed); + if (held && (mCacheItem != followed || held->getAssetUUID() != mCacheAsset) && held->getAssetUUID() != mFetching && + held->getAssetUUID() != mFailed) + { + refresh(); + } +} + +void ALFollowedNotecard::refresh() +{ + const LLUUID followed = item(); + if (followed.isNull()) + { + take(std::string(), std::string()); + return; + } + loadCache(); + const bool kept = mCacheItem == followed; + const LLViewerInventoryItem* held = gInventory.getItem(followed); + // The copy while it is this notecard's: the notecard as it stands + // where the item's asset is the one kept, or the last known while + // inventory has not come as far as the item; standing in, where the + // asset is another, until that comes. + take(kept ? mCacheText : std::string(), std::string()); + if (held && (!kept || held->getAssetUUID() != mCacheAsset)) + { + fetch(followed); + } +} + +void ALFollowedNotecard::fetch(const LLUUID& followed) +{ + const LLViewerInventoryItem* held = gInventory.getItem(followed); + if (!held || held->getAssetUUID() == mFetching) + { + return; + } + mFetching = held->getAssetUUID(); + const LLUUID asset = mFetching; + std::weak_ptr alive = mAlive; + ALScriptWorkspace::getInstance()->load(ALScriptRef(LLUUID::null, followed), [this, alive, followed, asset](const ALScriptLoaded& loaded) { + if (alive.expired()) + { + return; + } + mFetching.setNull(); + if (item() != followed) + { + // Another since. + return; + } + if (!loaded.error.empty() || !loaded.notecard) + { + // What was there stays; why it is not the notecard is said, and + // the asset is not asked for again. + mFailed = asset; + take(mText, loaded.error.empty() ? LLTrans::getString(mNotNotecard) : loaded.error); + return; + } + mCacheItem = followed; + mCacheAsset = loaded.assetId; + mCacheName = loaded.name; + mCacheText = loaded.text; + saveCache(); + take(loaded.text, std::string()); + }); +} + +std::string ALFollowedNotecard::cachePath() const +{ + return gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, mCacheFile); +} + +void ALFollowedNotecard::loadCache() +{ + if (mCacheLoaded) + { + return; + } + mCacheLoaded = true; + llifstream in(cachePath()); + LLSD kept; + if (!in.is_open() || LLSDSerialize::fromXML(kept, in) <= 0 || !kept.isMap()) + { + return; + } + mCacheItem = kept["item"].asUUID(); + mCacheAsset = kept["asset"].asUUID(); + mCacheName = kept["name"].asString(); + mCacheText = kept["text"].asString(); +} + +void ALFollowedNotecard::saveCache() +{ + LLSD kept; + kept["item"] = mCacheItem; + kept["asset"] = mCacheAsset; + kept["name"] = mCacheName; + kept["text"] = mCacheText; + llofstream out(cachePath()); + if (out.is_open()) + { + LLSDSerialize::toPrettyXML(kept, out); + } +} + +void ALFollowedNotecard::take(const std::string& text, const std::string& error) +{ + if (text == mText && error == mError) + { + return; + } + mText = text; + mError = error; + if (!mMoving) + { + mChanged(false); + } +} diff --git a/indra/newview/alfollowednotecard.h b/indra/newview/alfollowednotecard.h new file mode 100644 index 0000000000..a4a8d59e1c --- /dev/null +++ b/indra/newview/alfollowednotecard.h @@ -0,0 +1,103 @@ +/** + * @file alfollowednotecard.h + * @brief A notecard in inventory followed for its text, kept on disk while its asset is unchanged. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluuid.h" + +#include + +#include +#include + +// A notecard in inventory whose text something follows -- the vimrc, +// snippets -- by its item, which a per-account setting holds, since the +// notecard is the account's. +// +// Its text is kept on disk with the asset it was read from. An asset never +// changes: an edit to the notecard makes a new one and gives the item its +// id, so while the item's asset is the one kept the copy is the notecard as +// it stands, and it is read from disk rather than fetched again -- at once, +// before inventory has so much as loaded. An item that has moved on to +// another asset is fetched, the copy standing in until the new text comes. +class ALFollowedNotecard +{ +public: + // The per-account setting holding the item; the file, in the account's + // folder, the copy is kept in; and the string said where the item is + // not a notecard that reads, by its name in strings.xml. + ALFollowedNotecard(std::string setting, std::string cache_file, std::string not_notecard); + ~ALFollowedNotecard(); + + // The notecard followed, null where none is; its name, as last seen. + LLUUID item() const; + std::string name() const; + // Its text as last read, and why it could not be read the last time it + // was tried, empty where it was. + const std::string& text() const { return mText; } + const std::string& error() const { return mError; } + // Another notecard followed, or none with null. + void use(const LLUUID& item); + + // Fetched again where the item has another asset now -- saved since, + // here or elsewhere -- or has come into inventory with one. + void check(); + + // Said whenever the text or the error changes; `moved` where it is + // another notecard, or none, whatever its text. + typedef boost::signals2::signal changed_signal_t; + boost::signals2::connection onChanged(const changed_signal_t::slot_type& slot) { return mChanged.connect(slot); } + +private: + void refresh(); + void fetch(const LLUUID& item); + std::string cachePath() const; + void loadCache(); + void saveCache(); + void take(const std::string& text, const std::string& error); + + const std::string mSetting; + const std::string mCacheFile; + const std::string mNotNotecard; + std::string mText; + std::string mError; + // The copy kept on disk: whose, from which asset, its name and its + // text. + bool mCacheLoaded = false; + LLUUID mCacheItem; + LLUUID mCacheAsset; + std::string mCacheName; + std::string mCacheText; + // The asset a fetch is on its way for, so that another is not asked; + // and the one that could not be read, asked again only once the item + // has another or another item is followed. + LLUUID mFetching; + LLUUID mFailed; + // While another notecard is taken up: said once, as moved. + bool mMoving = false; + boost::signals2::scoped_connection mSettingChanged; + changed_signal_t mChanged; + // Whether this is still here, for what a fetch calls back. + std::shared_ptr mAlive = std::make_shared(true); +}; diff --git a/indra/newview/alscriptcrumbsbar.cpp b/indra/newview/alscriptcrumbsbar.cpp index 119835c6ed..83bcc353d8 100644 --- a/indra/newview/alscriptcrumbsbar.cpp +++ b/indra/newview/alscriptcrumbsbar.cpp @@ -147,6 +147,7 @@ ALScriptCrumbsBar::Steady ALScriptCrumbsBar::steadyOf(Doc& doc) const steady.target = mWindow->weightTarget(doc); steady.expandable = doc.expandedEditor != nullptr; steady.expanded = doc.shownView() == Doc::View::Expanded; + steady.wanted = mWindow->preprocessedWhy(doc); steady.optimized = steady.expanded && doc.uploaded.valid && doc.uploaded.version == doc.editor->document().version(); steady.codeBefore = doc.uploaded.codeBefore; steady.codeAfter = doc.uploaded.codeAfter; @@ -182,6 +183,15 @@ void ALScriptCrumbsBar::lead(Doc& doc, std::vector& parts) const parts.push_back({ banner, std::string(), std::string() }); parts.back().drop = DROP_LEAD; } + // A preprocessed view of a script a save sends as written -- one asked + // for, the preprocessor off for it: said, lest the view be taken for + // what goes up. + if (doc.expandedEditor && !doc.notecard && mWindow->preprocessedWhy(doc) == ALPreprocessor::Wanted::No) + { + parts.push_back({ mServices->words("TrailerNotPreprocessed"), "expanded", mServices->words("TrailerNotPreprocessedTip") }); + parts.back().color = doc.editor->markColor(ALCodeEditor::Mark::Warning); + parts.back().drop = DROP_LEAD; + } } void ALScriptCrumbsBar::place(Doc& doc, std::vector& parts) const @@ -474,8 +484,16 @@ void ALScriptCrumbsBar::views(Doc& doc, std::vector& parts) const if (doc.expandedEditor) { const bool expanded = doc.shownView() == Doc::View::Expanded; - parts.push_back( - { mServices->words(expanded ? "TrailerExpanded" : "TrailerSource"), "expanded", expanded ? mTips.expanded : mTips.source }); + // And why a save preprocesses it, where one does. + std::string tip = expanded ? mTips.expanded : mTips.source; + const ALPreprocessor::Wanted why = mWindow->preprocessedWhy(doc); + if (why != ALPreprocessor::Wanted::No) + { + tip += " " + mServices->words(why == ALPreprocessor::Wanted::Enveloped ? "TrailerPreprocessedEnveloped" + : why == ALPreprocessor::Wanted::Directives ? "TrailerPreprocessedDirectives" + : "TrailerPreprocessedSetting"); + } + parts.push_back({ mServices->words(expanded ? "TrailerExpanded" : "TrailerSource"), "expanded", tip }); parts.back().drop = DROP_VIEW; } } diff --git a/indra/newview/alscriptcrumbsbar.h b/indra/newview/alscriptcrumbsbar.h index a192f8e733..94ba983911 100644 --- a/indra/newview/alscriptcrumbsbar.h +++ b/indra/newview/alscriptcrumbsbar.h @@ -25,6 +25,7 @@ #pragma once #include "alscriptstudiodoc.h" +#include "alpreprocessor.h" #include "alscriptweight.h" #include "aljumpbar.h" #include "almenuslot.h" @@ -67,6 +68,8 @@ class ALScriptCrumbsBar : public LLPanel virtual std::string vimBanner() const = 0; virtual void problemCounts(const Doc& doc, S32& errors, S32& warnings) const = 0; virtual std::optional weightTarget(const Doc& doc) const = 0; + // Why a save preprocesses the tab, or that it does not. + virtual ALPreprocessor::Wanted preprocessedWhy(const Doc& doc) const = 0; protected: ~Window() = default; @@ -174,9 +177,10 @@ class ALScriptCrumbsBar : public LLPanel // What a save sends. bool notecard = false; size_t assetBytes = 0; - // The views'. + // The views', and whether a save preprocesses, and why. bool expandable = false; bool expanded = false; + ALPreprocessor::Wanted wanted = ALPreprocessor::Wanted::No; // The colours the weight and the size are said in past a limit. LLColor4 errorColor; LLColor4 warningColor; diff --git a/indra/newview/alscriptexplorerpane.cpp b/indra/newview/alscriptexplorerpane.cpp index 1063b0e726..77ceb55c31 100644 --- a/indra/newview/alscriptexplorerpane.cpp +++ b/indra/newview/alscriptexplorerpane.cpp @@ -441,6 +441,7 @@ void ALScriptExplorerPane::contentsHeard(const ALScriptContents& contents) mWindow->explorerPinsChanged(); } mStale = true; + ALScriptStudioServices::TabsHeld held(*mServices); for (Model::Opening& one : heard.opening) { mServices->openScript(one.ref, one.name, std::move(one.text)); @@ -552,9 +553,25 @@ void ALScriptExplorerPane::fill() : bracketed(mServices->words("KindAwayIn", where)); if (const ALScriptRegionUsage::Usage* reserved = row.present ? usage.usageOf(row.value["root"].asUUID()) : nullptr) { - LLStringUtil::format_map_t size; - size["[RESERVED]"] = llformat("%f", (F64)reserved->memory / 1024.0); - out.suffix += bracketed(mServices->words("ExplorerReserved", size)); + // What it reserves, and the time its scripts take where + // the region tells an estate manager. + std::string said; + if (reserved->hasMemory()) + { + LLStringUtil::format_map_t size; + size["[RESERVED]"] = llformat("%f", (F64)reserved->memory / 1024.0); + said = mServices->words("ExplorerReserved", size); + } + if (reserved->hasTime()) + { + LLStringUtil::format_map_t time; + time["[TIME]"] = llformat("%.3f", reserved->time); + said += (said.empty() ? "" : ", ") + mServices->words("ExplorerTime", time); + } + if (!said.empty()) + { + out.suffix += bracketed(said); + } } out.icon = row.many ? "Inv_Object_Multi" : "Inv_Object"; break; @@ -676,7 +693,8 @@ void ALScriptExplorerPane::onChosen() { // A script or notecard opened; an object, a prim or a linkset's row of // prims holding nothing opened where it is folded, folded where it is - // open. + // open. The tabs made once, as the last is opened. + ALScriptStudioServices::TabsHeld held(*mServices); for (const LLSD& value : mTree->chosen()) { const std::optional row = Choice::of(value); diff --git a/indra/newview/alscriptobjectcheck.cpp b/indra/newview/alscriptobjectcheck.cpp index d4831b2e82..2d7cf86bee 100644 --- a/indra/newview/alscriptobjectcheck.cpp +++ b/indra/newview/alscriptobjectcheck.cpp @@ -126,9 +126,9 @@ void ALScriptObjectCheck::begin(const Window::Listed::One& one) void ALScriptObjectCheck::checkRead(const Window::Listed::One& one, const Window::Read& read) { - // Read as a tab reads it: as the preprocessor makes it where it runs, - // or where the script went up in its envelope; as written otherwise. - if (!read.enveloped && !mWindow.preprocessing()) + // Read as a tab reads it: as the preprocessor makes it where a save + // would run it (ALPreprocessor::wanted); as written otherwise. + if (ALPreprocessor::wanted(read.text, read.lua, read.enveloped, mWindow.preprocessing()) == ALPreprocessor::Wanted::No) { ask(one, read, nullptr); return; diff --git a/indra/newview/alscriptproblemspane.cpp b/indra/newview/alscriptproblemspane.cpp index 73dbcfee15..deacb98b98 100644 --- a/indra/newview/alscriptproblemspane.cpp +++ b/indra/newview/alscriptproblemspane.cpp @@ -352,7 +352,7 @@ bool ALScriptProblemsPane::postBuild() void ALScriptProblemsPane::changed(const Doc& doc) { - mStore.replace(doc.id, doc.shown); + mStore.replace(doc.id, doc.shownRows); // The list, where it lists this script's: the one it is about, or // every one open. Doc* shown = listed(); @@ -672,7 +672,7 @@ void ALScriptProblemsPane::fill(const Doc* doc) }; for (const Doc* each : docs) { - count(each->shown); + count(each->shown()); } for (const Checked* each : checked) { diff --git a/indra/newview/alscriptregionusage.cpp b/indra/newview/alscriptregionusage.cpp index f7d876950b..cb2602a139 100644 --- a/indra/newview/alscriptregionusage.cpp +++ b/indra/newview/alscriptregionusage.cpp @@ -26,6 +26,10 @@ #include "alscriptregionusage.h" +#include "llcachename.h" + +#include + namespace { // The objects of a list of groups -- an answer's attachment points, or @@ -118,6 +122,48 @@ void ALScriptRegionUsage::ask(const std::vector& roots, F64 now) } }); } + // Time, of an estate manager: each owner's objects at once, an owner at + // most once a minute, whatever was asked of memory. + if (!mWorld.mayAskTime || !mWorld.askTime || !mWorld.ownerOf || !mWorld.mayAskTime()) + { + return; + } + // Each owner asked for now, with the objects of theirs in hand. + std::vector>> owners; + for (const LLUUID& root : roots) + { + if (!mWorld.kindOf || mWorld.kindOf(root) == World::Kind::None) + { + continue; + } + const std::string owner = mWorld.ownerOf(root); + if (owner.empty()) + { + continue; + } + const auto listed = std::find_if(owners.begin(), owners.end(), [&owner](const auto& one) { return one.first == owner; }); + if (listed != owners.end()) + { + listed->second.push_back(root); + continue; + } + const auto asked = mTimeAsked.find(owner); + if (asked != mTimeAsked.end() && now - asked->second < ASK_EVERY) + { + continue; + } + mTimeAsked[owner] = now; + owners.push_back({ owner, { root } }); + } + for (auto& [owner, theirs] : owners) + { + mWorld.askTime(owner, [this, alive, theirs = std::move(theirs)](const times_t& times) { + if (alive.lock()) + { + heardTimes(times, LLDate::now(), theirs); + } + }); + } } // static @@ -139,12 +185,84 @@ void ALScriptRegionUsage::heard(const usages_t& usages) return; } // Each object named is answered for as of the last asking, asked with - // the others or not: a parcel's answer is every object on it. + // the others or not: a parcel's answer is every object on it. Its time, + // where said, stays. for (const auto& [id, usage] : usages) { - mUsages[id] = usage; + Usage& kept = mUsages[id]; + kept.memory = usage.memory; + kept.urls = usage.urls; + kept.when = usage.when; const auto asked = mAsked.find(id); mAsked[id] = asked != mAsked.end() ? std::max(asked->second, mNow) : mNow; } mHeard(); } + +void ALScriptRegionUsage::heardTimes(const times_t& times, const LLDate& when, const std::vector& asked) +{ + bool changed = false; + for (const auto& [id, time] : times) + { + Usage& kept = mUsages[id]; + kept.time = time; + kept.timeWhen = when; + changed = true; + } + // An answer short of the most Top Scripts gives names every object of + // the owner's that runs scripts: one asked for and not named runs + // none now, and what was said of it stands no longer. + if (times.size() < TOP_SCRIPTS_MOST) + { + for (const LLUUID& root : asked) + { + const auto kept = mUsages.find(root); + if (!times.contains(root) && kept != mUsages.end() && kept->second.hasTime()) + { + kept->second.time = -1.f; + kept->second.timeWhen = LLDate(0.0); + changed = true; + } + } + } + if (changed) + { + mHeard(); + } +} + +// static +std::optional ALScriptRegionUsage::answering(const std::vector& waiting, const std::vector& named, + const std::optional& topObjectsOwner) +{ + std::vector keys; + keys.reserve(named.size()); + for (const std::string& name : named) + { + keys.push_back(ownerKey(name)); + } + const std::string theirs = topObjectsOwner ? ownerKey(*topObjectsOwner) : std::string(); + for (size_t i = 0; i < waiting.size(); ++i) + { + const std::string key = ownerKey(waiting[i]); + if ((!topObjectsOwner || key != theirs) && std::find(keys.begin(), keys.end(), key) != keys.end()) + { + return i; + } + } + if (!waiting.empty() && !topObjectsOwner) + { + return 0; + } + return std::nullopt; +} + +// static +std::string ALScriptRegionUsage::ownerKey(std::string name) +{ + // The region sends names with spaces after, at times. + LLStringUtil::trim(name); + name = LLCacheName::buildUsername(name); + LLStringUtil::toLower(name); + return name; +} diff --git a/indra/newview/alscriptregionusage.h b/indra/newview/alscriptregionusage.h index 038717cfbb..18fa115cd3 100644 --- a/indra/newview/alscriptregionusage.h +++ b/indra/newview/alscriptregionusage.h @@ -33,13 +33,16 @@ #include #include +#include +#include #include // What the region reserves for the scripts of the objects a studio has in // hand, as it tells the Script Limits and My Scripts floaters: memory and // URLs for each object, by its root. Never for each script, and never what // a script uses: the region counts a Mono script at its limit, 64 KB unless -// llSetMemoryLimit says less. Time it tells only an estate manager. +// llSetMemoryLimit says less. Time it tells only an estate manager, by Top +// Scripts: each object's, asked for by its owner's name. // // An attachment of the agent's is asked of AttachmentResources, which // answers for all of them at once; an object on land, of LandResources, @@ -52,11 +55,20 @@ class ALScriptRegionUsage public: struct Usage { - // Bytes. + // Bytes; and when the region said so, an epoch's nought where it has + // not. S64 memory = 0; S32 urls = 0; - LLDate when; + LLDate when = LLDate(0.0); + // The time its scripts take, in milliseconds a frame, as Top Scripts + // says it to an estate manager; negative where it has not said. + F32 time = -1.f; + LLDate timeWhen = LLDate(0.0); + bool hasMemory() const { return when.secondsSinceEpoch() > 0.0; } + bool hasTime() const { return time >= 0.f; } }; + // Time said, by each object's id. + typedef boost::unordered_flat_map times_t; struct World { @@ -76,6 +88,15 @@ class ALScriptRegionUsage // The parcel an object stands on asked about, its details' answer // to `told` the same way. std::function told)> askLand; + // Whether the region would tell the agent its scripts' time: an + // estate manager of it. + std::function mayAskTime; + // The name Top Scripts knows an object's owner by; empty where it + // is not known, and the object's time is not asked for. + std::function ownerOf; + // Top Scripts asked for, of one owner's objects; `told` with each + // one's time, once, or not at all. + std::function told)> askTime; }; // How long an answer stands before an object is asked about again. @@ -95,8 +116,30 @@ class ALScriptRegionUsage static void readAttachments(const LLSD& answer, const LLDate& when, usages_t& out); static void readDetails(const LLSD& answer, const LLDate& when, usages_t& out); - // What an answer said kept, and heard: the objects it named. + // What an answer said kept, and heard: the objects it named. Memory + // and time each keep what the other said. An answer of times is one + // owner's, `asked` the objects of theirs it was asked for: those it + // does not name, where it names fewer than TOP_SCRIPTS_MOST, have no + // time any more. void heard(const usages_t& usages); + void heardTimes(const times_t& times, const LLDate& when, const std::vector& asked = {}); + // The most objects Top Scripts names in one answer. + static constexpr size_t TOP_SCRIPTS_MOST = 100; + + // Which of the asks for time waiting, oldest first, by owner, an answer + // of Top Scripts by owner is for, the owners its rows name given, and + // the owner Top Objects waits on such an answer for, where it is open + // and does. With Top Objects not waiting, every such answer is the + // studio's: the oldest ask's whose owner the rows name, else the + // oldest's. With it waiting, the answer is its own, none -- but one + // whose rows name an owner the studio waits for and Top Objects did + // not ask about. The region's answer says neither who asked nor what + // for. Names compared by ownerKey. + static std::optional answering(const std::vector& waiting, const std::vector& named, + const std::optional& topObjectsOwner); + // An owner's name as a username, lowered, whichever form it came in: + // "First Resident" and "first" alike. + static std::string ownerKey(std::string name); typedef boost::signals2::signal heard_signal_t; boost::signals2::connection onHeard(const heard_signal_t::slot_type& slot) { return mHeard.connect(slot); } @@ -106,6 +149,8 @@ class ALScriptRegionUsage // When each object, and the attachments, were last asked about. boost::unordered_flat_map mAsked; F64 mAttachmentsAsked = -ASK_EVERY; + // When each owner's objects' time was last asked for. + boost::unordered_flat_map mTimeAsked; // The clock as the last asking said it, which an answer is counted by. F64 mNow = 0.0; heard_signal_t mHeard; diff --git a/indra/newview/alscriptsearchpane.cpp b/indra/newview/alscriptsearchpane.cpp index d563068cff..176d27d119 100644 --- a/indra/newview/alscriptsearchpane.cpp +++ b/indra/newview/alscriptsearchpane.cpp @@ -250,16 +250,45 @@ void ALScriptSearchPane::searchIncludes(const ALScriptRef& ref, const std::strin } for (const Window::Included& one : mWindow->includesOf(ref, file, name, text, lua)) { - if (mIncludesSearched.insert(one.path).second) + if (!mIncludesSearched.insert(one.path).second) { - const ALScriptSearch::Kept kept = mSearch.search(ALScriptRef(), one.name, mServices->words("SearchIncluded"), ALTextDocument(one.text), 0, - std::string(), false, false, one.path); - if (kept == ALScriptSearch::Kept::Added) - { - addRows(mSearch.found().size() - 1); - } + continue; } + // Sought away from the main thread, as a script not open is, and + // counted as to come until the answer is back: an include can be a + // library many times the script's size. + mSearch.asked(); + const U32 generation = mSearch.generation(); + const LLHandle handle = getHandle(); + const std::string where = mServices->words("SearchIncluded"); + mWindow->matchApart(std::make_shared(one.text), mSearch.query(), mSearch.options(), + [handle, generation, where, name = one.name, path = one.path](ALScriptSearch::Matched found) { + if (ALScriptSearchPane* pane = ALViewType::as(handle.get())) + { + pane->includeMatched(generation, where, name, path, std::move(found)); + } + }); + } +} + +void ALScriptSearchPane::includeMatched(U32 generation, const std::string& where, const std::string& name, const std::string& path, + ALScriptSearch::Matched found) +{ + if (!mSearch.answered(generation)) + { + return; } + // Kept as its own, without its text: Replace All leaves an include be. + const ALScriptSearch::Kept kept = mSearch.keep(ALScriptRef(), name, where, std::move(found), 0, std::string(), nullptr, false, path); + if (mSearch.badPattern()) + { + mCount->setToolTip(mSearch.patternError()); + } + else if (kept == ALScriptSearch::Kept::Added) + { + addRows(mSearch.found().size() - 1); + } + settled(); } void ALScriptSearchPane::run() @@ -771,6 +800,8 @@ void ALScriptSearchPane::replaceAll() // since the search is left alone, and said so. const std::vector found = mSearch.found(); S32 places = 0, scripts = 0, opened = 0, left = 0; + // The tabs it opens made once, as the last is opened. + ALScriptStudioServices::TabsHeld held(*mServices); for (const ALScriptSearch::Found& one : found) { Doc* here = tabOf(one); diff --git a/indra/newview/alscriptsearchpane.h b/indra/newview/alscriptsearchpane.h index bd0dfa01f7..6b5380ce06 100644 --- a/indra/newview/alscriptsearchpane.h +++ b/indra/newview/alscriptsearchpane.h @@ -173,6 +173,9 @@ class ALScriptSearchPane : public LLPanel // this search's. void matched(U32 generation, const std::string& where, const ALScriptRef& ref, const std::string& name, const std::string& text, bool notecard, ALScriptSearch::Matched found); + // What an include held of the words, back from away from the main + // thread. + void includeMatched(U32 generation, const std::string& where, const std::string& name, const std::string& path, ALScriptSearch::Matched found); void buildSentence(); void onChanged(); void searchOpen(const Doc& doc); diff --git a/indra/newview/alscriptsnippets.cpp b/indra/newview/alscriptsnippets.cpp index 4bd42cc93c..be951780d6 100644 --- a/indra/newview/alscriptsnippets.cpp +++ b/indra/newview/alscriptsnippets.cpp @@ -41,6 +41,7 @@ namespace ALScriptSnippets { std::vector sAll[2]; bool sLoaded[2] = { false, false }; + std::vector sFollowed[2]; std::string fileName(bool lua) { @@ -91,7 +92,7 @@ namespace ALScriptSnippets { // One without a name or a body is one being written, and is // kept in the tab rather than in the file. - if (one.builtin || one.name.empty() || one.body.empty()) + if (one.builtin || one.followed || one.name.empty() || one.body.empty()) { continue; } @@ -220,8 +221,9 @@ namespace ALScriptSnippets if (there == own.end()) { own.push_back(one); - own.back().name = name; - own.back().builtin = false; + own.back().name = name; + own.back().builtin = false; + own.back().followed = false; ++(n == 1 ? merged.added : merged.renamed); break; } @@ -243,11 +245,40 @@ namespace ALScriptSnippets sLoaded[lua ? 1 : 0] = true; out.clear(); readFrom(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, fileName(lua)), true, out); + const std::vector& notecard = sFollowed[lua ? 1 : 0]; + out.insert(out.end(), notecard.begin(), notecard.end()); readFrom(path(lua), false, out); } return out; } + bool follow(const std::string& notecard_text) + { + std::vector lsl, slua, unused; + if (!notecard_text.empty() && + (!readNotecard(notecard_text, false, lsl, unused) || !readNotecard(notecard_text, true, unused, slua))) + { + return false; + } + for (std::vector* one : { &lsl, &slua }) + { + for (Snippet& snippet : *one) + { + snippet.followed = true; + } + } + sFollowed[0] = std::move(lsl); + sFollowed[1] = std::move(slua); + forget(false); + forget(true); + return true; + } + + const std::vector& followed(bool lua) + { + return sFollowed[lua ? 1 : 0]; + } + std::vector own(bool lua) { std::vector out; diff --git a/indra/newview/alscriptsnippets.h b/indra/newview/alscriptsnippets.h index 407441be6a..acb8519d18 100644 --- a/indra/newview/alscriptsnippets.h +++ b/indra/newview/alscriptsnippets.h @@ -28,9 +28,10 @@ #include // What completion and Insert > Snippet offer: the viewer's snippets, from -// app_settings/snippets, and the scripter's own, from the same folder under -// the settings folder, which the preferences' Snippets tab edits and a -// scripter may also edit as XML. Each file is an LLSD array of maps with a +// app_settings/snippets; those of a notecard the scripter follows, as it +// stands; and the scripter's own, from the same folder under the settings +// folder, which the preferences' Snippets tab edits and a scripter may also +// edit as XML. Each file is an LLSD array of maps with a // name, the prefix completion offers it under, a line of detail, and the // body, where ${1:text}, ${2} and $1 are the places Tab goes through and $0 // is where the caret ends. @@ -44,11 +45,22 @@ namespace ALScriptSnippets std::string body; // The viewer's, which a scripter copies to change. bool builtin = false; + // The followed notecard's, which a scripter copies to change, or + // changes in the notecard. + bool followed = false; }; - // Every snippet of a language, the viewer's then the scripter's; read - // once, and again after either file changes. + // Every snippet of a language, the viewer's, the followed notecard's, + // then the scripter's; read once, and again after either file or the + // notecard changes. const std::vector& all(bool lua); + // The notecard followed, by its text, its snippets taken in place of + // those it had -- each in the language it says, one saying none in + // both; none with empty text. False, with none taken, where the text + // is not snippets. + bool follow(const std::string& notecard_text); + // The followed notecard's alone. + const std::vector& followed(bool lua); // The scripter's own alone, as their file holds them. std::vector own(bool lua); // The scripter's own written back, and offered from then on. False diff --git a/indra/newview/alscriptstudiochecking.cpp b/indra/newview/alscriptstudiochecking.cpp index c0218303af..4b1c3577c3 100644 --- a/indra/newview/alscriptstudiochecking.cpp +++ b/indra/newview/alscriptstudiochecking.cpp @@ -112,7 +112,25 @@ ALScriptStudioChecking::ALScriptStudioChecking(ALScriptStudioServices& services, bool ALScriptStudioChecking::preprocessed(const Doc& doc) const { - return doc.loaded && !doc.notecard && (doc.envelope.has_value() || ALScriptStudioViewer::get().preprocessing()); + return preprocessedWhy(doc) != ALPreprocessor::Wanted::No; +} + +ALPreprocessor::Wanted ALScriptStudioChecking::preprocessedWhy(const Doc& doc) const +{ + if (!doc.loaded || doc.notecard) + { + return ALPreprocessor::Wanted::No; + } + const auto directives = [&doc]() { + const ALTextDocument& text = doc.editor->document(); + if (doc.directivesOf != text.version()) + { + doc.directives = ALPreprocessor::usesDirectives([&text](S32 i) { return std::string_view(text.line(i)); }, text.lineCount(), doc.language.lua); + doc.directivesOf = text.version(); + } + return doc.directives; + }; + return ALPreprocessor::wanted(doc.envelope.has_value(), directives, ALScriptStudioViewer::get().preprocessing()); } ALScriptPreprocessor::Request ALScriptStudioChecking::preprocessRequest(const Doc& doc, bool with_source) const @@ -1285,17 +1303,33 @@ void ALScriptStudioChecking::offerImports(Doc& doc) void ALScriptStudioChecking::explainTransformWords(Doc& doc) { // A parse error on one of the preprocessor's words, with its transform - // off, is the transform's to explain (ALPreprocessor::transformAt). + // off, is the transform's to explain (ALPreprocessor::transformAt); one + // on a directive, with the script not preprocessed, the preprocessor's. using Transform = ALPreprocessor::Transform; - const bool preprocessing = ALScriptStudioViewer::get().preprocessing(); + const bool preprocessing = preprocessed(doc); const ALTextDocument& text = doc.editor->document(); const auto line = [&text](S32 index) { return std::string_view(text.line(index)); }; + const bool expanded = preprocessing; for (ALScriptProblem& problem : doc.check->analysis) { if (problem.severity != ALScriptProblem::Severity::Error || !problem.file.empty()) { continue; } + // A directive read as code, the script not preprocessed: the + // parser's complaint is the preprocessor's to explain, and what it + // would put in is nothing the line wants. + if (!expanded && problem.line >= 0 && problem.line < text.lineCount()) + { + const std::string_view at = line(problem.line); + const size_t code = at.find_first_not_of(" \t"); + if (code != std::string_view::npos && at[code] == '#') + { + problem.message += " " + mServices.words("PreprocHintDirective"); + problem.fixes.clear(); + continue; + } + } std::string word; const Transform transform = ALPreprocessor::transformAt(line, text.lineCount(), problem.line, word); // `inline` and `const` are taken off whenever the preprocessor @@ -1562,8 +1596,10 @@ void ALScriptStudioChecking::previewFixAll(Doc& doc, const FixPick& pick) bool ALScriptStudioChecking::applyPreviewed(Doc& doc) { + // Never nothing said: Apply that makes nothing says why. if (!doc.check->fixAllPreviewed) { + mServices.setStatus(mServices.words("FixAllNotPreviewed"), true); return false; } const Doc::Check::FixAllPreview previewed = *doc.check->fixAllPreviewed; @@ -1581,6 +1617,10 @@ bool ALScriptStudioChecking::fixAll(Doc& doc, const FixPick& pick) { if (!doc.loaded || !doc.modifiable) { + if (!pick.forSave) + { + mServices.setStatus(mServices.words("FixAllNotMade"), true); + } return false; } // Only the fixes made over the text as it stands (pickFixes). Made on @@ -1599,9 +1639,24 @@ bool ALScriptStudioChecking::fixAll(Doc& doc, const FixPick& pick) } } // Every one of them one step to undo: they were made over one check, - // and none meets another. - if (edits.empty() || !source.replaceAll(std::move(edits))) + // and none meets another. Asked for and none made, said: the problems + // they were for gone since, which a check says again, or the text + // refusing them. + if (edits.empty()) { + if (!pick.forSave) + { + mServices.setStatus(mServices.words("FixStale"), true); + schedule(doc, true); + } + return false; + } + if (!source.replaceAll(std::move(edits))) + { + if (!pick.forSave) + { + mServices.setStatus(mServices.words("FixAllNotMade"), true); + } return false; } source.undoJournal().label("fix"); @@ -1616,7 +1671,7 @@ void ALScriptStudioChecking::fixesOn(Doc& doc, S32 line, std::vectordocument().version(); - for (const Doc::Shown& shown : doc.shown) + for (const Doc::Shown& shown : doc.shown()) { if (!shown.file.empty() || shown.line != line || shown.fixesFor != now) { diff --git a/indra/newview/alscriptstudiochecking.h b/indra/newview/alscriptstudiochecking.h index c0f394791b..48b5f3df62 100644 --- a/indra/newview/alscriptstudiochecking.h +++ b/indra/newview/alscriptstudiochecking.h @@ -183,6 +183,9 @@ class ALScriptStudioChecking // where only where the script is matters; and what an include is // called. bool preprocessed(const Doc& doc) const; + // And why (ALPreprocessor::wanted): No for a notecard, or one not yet + // loaded. + ALPreprocessor::Wanted preprocessedWhy(const Doc& doc) const; ALScriptPreprocessor::Request preprocessRequest(const Doc& doc, bool with_source = true) const; std::string includeName(const Doc& doc, const std::string& path) const; // An LSL file on disk with no default state: an include's functions diff --git a/indra/newview/alscriptstudiodoc.cpp b/indra/newview/alscriptstudiodoc.cpp index 5826c1c70a..d543daedd5 100644 --- a/indra/newview/alscriptstudiodoc.cpp +++ b/indra/newview/alscriptstudiodoc.cpp @@ -351,11 +351,11 @@ std::string ALScriptStudioDoc::foundAs(const std::string& name, std::optional rows) { - shown = std::move(rows); + shownRows = std::make_shared>(std::move(rows)); shownErrors = 0; shownWarnings = 0; shownMigration = 0; - for (const Shown& one : shown) + for (const Shown& one : *shownRows) { shownErrors += one.level == Level::Error ? 1 : 0; shownWarnings += one.level == Level::Warning ? 1 : 0; @@ -365,7 +365,7 @@ void ALScriptStudioDoc::setShown(std::vector rows) const ALScriptStudioDoc::Shown* ALScriptStudioDoc::findShown(S32 line, S32 column, const std::string& file, const std::string& message) const { - for (const Shown& one : shown) + for (const Shown& one : shown()) { if (one.line == line && one.column == column && one.file == file && one.message == message) { @@ -380,7 +380,7 @@ std::vector ALScriptStudioDoc::pickFixes(const FixPick& pick std::vector taken; const U32 now = editor->document().version(); const bool only_safe = pick.forSave || (pick.key.empty() && !pick.migration); - for (const Shown& one : shown) + for (const Shown& one : shown()) { if (!one.file.empty() || one.fixesFor != now || (!pick.key.empty() && one.key != pick.key) || (pick.migration && !one.migration)) { diff --git a/indra/newview/alscriptstudiodoc.h b/indra/newview/alscriptstudiodoc.h index 6309342e97..80997eb468 100644 --- a/indra/newview/alscriptstudiodoc.h +++ b/indra/newview/alscriptstudiodoc.h @@ -298,6 +298,11 @@ struct ALScriptStudioDoc // and whose expanded code the other editor shows; a save runs // the preprocessor over the source and wraps both again. std::optional envelope; + // Whether the text holds a directive of the preprocessor's, as of a + // version of it (ALPreprocessor::usesDirectives): read as it is asked, + // which is often, and the text but now and then changed. + mutable U32 directivesOf = ~0u; + mutable bool directives = false; // The version a wrapped script was loaded at, where the first run over // its source is to be held up to its compiled half; none once it has // been. And the compiled half, where the source could not have made @@ -501,7 +506,14 @@ struct ALScriptStudioDoc // (ALScriptLintPass::migration). bool migration = false; }; - std::vector shown; + // The rows, shared with the Problems pane's store, which holds them + // as they are without a copy; never changed once put, only put again. + std::shared_ptr> shownRows; + const std::vector& shown() const + { + static const std::vector none; + return shownRows ? *shownRows : none; + } // How many of them are errors and warnings, counted as they are put // in: the tab's dot and the trailer ask on every key. S32 shownErrors = 0; diff --git a/indra/newview/alscriptstudioservices.h b/indra/newview/alscriptstudioservices.h index 069a985ae8..6b1415833d 100644 --- a/indra/newview/alscriptstudioservices.h +++ b/indra/newview/alscriptstudioservices.h @@ -107,6 +107,22 @@ class ALScriptStudioServices bool focus = true) = 0; // A place in a script, gone to once the script is open, or now. virtual void goToPlace(const ALScriptRef& ref, const std::string& name, S32 line, S32 column, S32 length) = 0; + // Many tabs opened at once: while held, what each would make again -- + // the strip, the toolbar, the lists -- waits, and is made once as the + // last lets go (Held below). Nothing where tabs make nothing. + virtual void holdTabs() {} + virtual void letGoOfTabs() {} + class TabsHeld + { + public: + explicit TabsHeld(ALScriptStudioServices& services) : mServices(services) { mServices.holdTabs(); } + ~TabsHeld() { mServices.letGoOfTabs(); } + TabsHeld(const TabsHeld&) = delete; + TabsHeld& operator=(const TabsHeld&) = delete; + + private: + ALScriptStudioServices& mServices; + }; // A list's row chosen and its place shown: the keyboard left in the // list to walk on, or, `to_editor`, taken to the script to type there. virtual void revealed(LLUICtrl* list, bool to_editor) = 0; diff --git a/indra/newview/alscriptstudiosnippetnotecard.cpp b/indra/newview/alscriptstudiosnippetnotecard.cpp new file mode 100644 index 0000000000..14d551b7ef --- /dev/null +++ b/indra/newview/alscriptstudiosnippetnotecard.cpp @@ -0,0 +1,83 @@ +/** + * @file alscriptstudiosnippetnotecard.cpp + * @brief The snippet notecard a scripter follows: its snippets offered beside their own, as it stands. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alscriptstudiosnippetnotecard.h" + +#include "alfollowednotecard.h" +#include "alscriptsnippets.h" +#include "lltrans.h" + +ALScriptStudioSnippetNotecard::~ALScriptStudioSnippetNotecard() = default; + +ALScriptStudioSnippetNotecard::ALScriptStudioSnippetNotecard() + : mNotecard(std::make_unique(SETTING, "script_studio_snippet_notecard.xml", "SnippetsFollowedNotNotecard")) +{ + mNotecardChanged = mNotecard->onChanged([this](bool) { + take(); + mChanged(); + }); + take(); +} + +LLUUID ALScriptStudioSnippetNotecard::notecard() const +{ + return mNotecard->item(); +} + +std::string ALScriptStudioSnippetNotecard::notecardName() const +{ + return mNotecard->name(); +} + +void ALScriptStudioSnippetNotecard::useNotecard(const LLUUID& item) +{ + mNotecard->use(item); +} + +void ALScriptStudioSnippetNotecard::check() +{ + if (mSinceCheck.getElapsedTimeF32() < 1.f) + { + return; + } + mSinceCheck.reset(); + mNotecard->check(); +} + +void ALScriptStudioSnippetNotecard::take() +{ + // Text that is not snippets gives none, and says so: what is offered + // is what the notecard holds. + mError = mNotecard->error(); + if (!ALScriptSnippets::follow(mNotecard->text())) + { + ALScriptSnippets::follow(std::string()); + if (mError.empty()) + { + mError = LLTrans::getString("SnippetsFollowedNotSnippets"); + } + } +} diff --git a/indra/newview/alscriptstudiosnippetnotecard.h b/indra/newview/alscriptstudiosnippetnotecard.h new file mode 100644 index 0000000000..859aa46e74 --- /dev/null +++ b/indra/newview/alscriptstudiosnippetnotecard.h @@ -0,0 +1,78 @@ +/** + * @file alscriptstudiosnippetnotecard.h + * @brief The snippet notecard a scripter follows: its snippets offered beside their own, as it stands. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llsingleton.h" +#include "lltimer.h" +#include "lluuid.h" + +#include + +#include +#include + +class ALFollowedNotecard; + +// The snippet notecard a scripter follows, dropped on the box for it in +// the studio's preferences: its snippets offered beside the viewer's and +// their own (ALScriptSnippets::follow) as the notecard stands, whoever +// changes it -- a team's notecard, kept up to date by one of them. Followed +// by ALFollowedNotecard, which keeps its text on disk with the asset it +// was read from. +class ALScriptStudioSnippetNotecard final : public LLSingleton +{ + LLSINGLETON(ALScriptStudioSnippetNotecard); + ~ALScriptStudioSnippetNotecard() override; + +public: + // The per-account setting that holds the notecard's item. + static constexpr const char* SETTING = "ALScriptStudioSnippetsNotecard"; + + // The notecard followed, null where none is; its name, as last seen; + // and why its snippets could not be read the last time they were + // tried, empty where they were. + LLUUID notecard() const; + std::string notecardName() const; + const std::string& error() const { return mError; } + // The notecard dropped on the box, or null for none. + void useNotecard(const LLUUID& item); + + // Said whenever its snippets, or which notecard it is, change. + typedef boost::signals2::signal changed_signal_t; + boost::signals2::connection onChanged(const changed_signal_t::slot_type& slot) { return mChanged.connect(slot); } + + // The notecard fetched where its item has another asset now. At most + // once a second, for a caller each frame. + void check(); + +private: + void take(); + + std::string mError; + LLTimer mSinceCheck; + std::unique_ptr mNotecard; + boost::signals2::scoped_connection mNotecardChanged; + changed_signal_t mChanged; +}; diff --git a/indra/newview/alscriptstudiovimrc.cpp b/indra/newview/alscriptstudiovimrc.cpp index 4078504b5f..6d1144db61 100644 --- a/indra/newview/alscriptstudiovimrc.cpp +++ b/indra/newview/alscriptstudiovimrc.cpp @@ -27,14 +27,10 @@ #include "alscriptstudiovimrc.h" #include "alfilewrite.h" -#include "alscriptworkspace.h" +#include "alfollowednotecard.h" #include "alwatchedfile.h" #include "llfile.h" -#include "llinventorymodel.h" -#include "llsdserialize.h" #include "lltrans.h" -#include "llviewercontrol.h" -#include "llviewerinventory.h" namespace { @@ -45,15 +41,16 @@ namespace ALScriptStudioVimrc::~ALScriptStudioVimrc() = default; ALScriptStudioVimrc::ALScriptStudioVimrc() + : mNotecard(std::make_unique(SETTING, "script_studio_vimrc.xml", "VimrcNotNotecard")) { - if (LLControlVariable* control = gSavedPerAccountSettings.getControl(SETTING)) - { - mSettingChanged = control->getSignal()->connect([this](LLControlVariable*, const LLSD&, const LLSD&) { - refresh(); + mNotecardChanged = mNotecard->onChanged([this](bool moved) { + refresh(); + if (moved) + { // Where it comes from changed, whatever the text. mChanged(); - }); - } + } + }); refresh(); } @@ -63,35 +60,19 @@ std::string ALScriptStudioVimrc::filePath() return gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "vimrc"); } -// static -std::string ALScriptStudioVimrc::cachePath() -{ - return gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, "script_studio_vimrc.xml"); -} - LLUUID ALScriptStudioVimrc::notecard() const { - const LLControlVariable* control = gSavedPerAccountSettings.getControl(SETTING); - return control ? LLUUID(control->getValue().asString()) : LLUUID::null; + return mNotecard->item(); } std::string ALScriptStudioVimrc::notecardName() const { - const LLUUID item = notecard(); - if (item.isNull()) - { - return std::string(); - } - if (const LLViewerInventoryItem* held = gInventory.getItem(item)) - { - return held->getName(); - } - return mCacheItem == item ? mCacheName : std::string(); + return mNotecard->name(); } void ALScriptStudioVimrc::useNotecard(const LLUUID& item) { - gSavedPerAccountSettings.setString(SETTING, item.isNull() ? std::string() : item.asString()); + mNotecard->use(item); } void ALScriptStudioVimrc::check(bool now) @@ -101,8 +82,7 @@ void ALScriptStudioVimrc::check(bool now) return; } mSinceCheck.reset(); - const LLUUID item = notecard(); - if (item.isNull()) + if (notecard().isNull()) { // Watched, and read as it changes; read now where asked, or where // it has yet to be watched. @@ -112,35 +92,19 @@ void ALScriptStudioVimrc::check(bool now) } return; } - const LLViewerInventoryItem* held = gInventory.getItem(item); - if (held && (mCacheItem != item || held->getAssetUUID() != mCacheAsset) && held->getAssetUUID() != mFetching) - { - refresh(); - } + mNotecard->check(); } void ALScriptStudioVimrc::refresh() { - const LLUUID item = notecard(); - if (item.isNull()) + if (notecard().isNull()) { readFile(); return; } // Not the vimrc while the notecard is. mWatch.reset(); - loadCache(); - const bool kept = mCacheItem == item; - const LLViewerInventoryItem* held = gInventory.getItem(item); - // The copy while it is this notecard's: the notecard as it stands - // where the item's asset is the one kept, or the last known while - // inventory has not come as far as the item; standing in, where the - // asset is another, until that comes. - take(kept ? mCacheText : std::string(), std::string()); - if (held && (!kept || held->getAssetUUID() != mCacheAsset)) - { - fetch(item); - } + take(mNotecard->text(), mNotecard->error()); } void ALScriptStudioVimrc::readFile() @@ -171,74 +135,6 @@ void ALScriptStudioVimrc::readFile() take(text, std::string()); } -void ALScriptStudioVimrc::fetch(const LLUUID& item) -{ - const LLViewerInventoryItem* held = gInventory.getItem(item); - if (!held || held->getAssetUUID() == mFetching) - { - return; - } - mFetching = held->getAssetUUID(); - ALScriptWorkspace::getInstance()->load(ALScriptRef(LLUUID::null, item), [item](const ALScriptLoaded& loaded) { - if (!ALScriptStudioVimrc::instanceExists()) - { - return; - } - ALScriptStudioVimrc& self = ALScriptStudioVimrc::instance(); - self.mFetching.setNull(); - if (self.notecard() != item) - { - // Another since. - return; - } - if (!loaded.error.empty() || !loaded.notecard) - { - // What was there stays; why it is not the notecard is said. - self.take(self.mText, loaded.error.empty() ? LLTrans::getString("VimrcNotNotecard") : loaded.error); - return; - } - self.mCacheItem = item; - self.mCacheAsset = loaded.assetId; - self.mCacheName = loaded.name; - self.mCacheText = loaded.text; - self.saveCache(); - self.take(loaded.text, std::string()); - }); -} - -void ALScriptStudioVimrc::loadCache() -{ - if (mCacheLoaded) - { - return; - } - mCacheLoaded = true; - llifstream in(cachePath()); - LLSD kept; - if (!in.is_open() || LLSDSerialize::fromXML(kept, in) <= 0 || !kept.isMap()) - { - return; - } - mCacheItem = kept["item"].asUUID(); - mCacheAsset = kept["asset"].asUUID(); - mCacheName = kept["name"].asString(); - mCacheText = kept["text"].asString(); -} - -void ALScriptStudioVimrc::saveCache() -{ - LLSD kept; - kept["item"] = mCacheItem; - kept["asset"] = mCacheAsset; - kept["name"] = mCacheName; - kept["text"] = mCacheText; - llofstream out(cachePath()); - if (out.is_open()) - { - LLSDSerialize::toPrettyXML(kept, out); - } -} - void ALScriptStudioVimrc::take(const std::string& text, const std::string& error) { if (text == mText && error == mError) diff --git a/indra/newview/alscriptstudiovimrc.h b/indra/newview/alscriptstudiovimrc.h index be0c547205..2a8db303e9 100644 --- a/indra/newview/alscriptstudiovimrc.h +++ b/indra/newview/alscriptstudiovimrc.h @@ -33,21 +33,14 @@ #include #include +class ALFollowedNotecard; class ALWatchedFile; // The vimrc Script Studio's vim reads: the file vimrc in the viewer's // settings folder, or a notecard dropped on the box for it in the -// studio's preferences, which is the vimrc while it is set -- kept in the -// account's settings, since the notecard is the account's. -// -// A notecard's text is kept on disk with the asset it was read from. -// An asset never changes: an edit to the notecard makes a new one and -// gives the item its id, so while the item's asset is the one kept the -// copy is the notecard as it stands, and it is read from disk rather -// than fetched again -- at once, before inventory has so much as loaded, -// so that the mappings are there from the first key. An item that has -// moved on to another asset is fetched, the copy standing in until the -// new text comes. +// studio's preferences, which is the vimrc while it is set -- followed by +// ALFollowedNotecard, which keeps its text on disk with the asset it was +// read from, so that the mappings are there from the first key. class ALScriptStudioVimrc final : public LLSingleton { LLSINGLETON(ALScriptStudioVimrc); @@ -87,12 +80,7 @@ class ALScriptStudioVimrc final : public LLSingleton // What the vimrc is now, from wherever the setting says. void refresh(); void readFile(); - void fetch(const LLUUID& item); - // The notecard's copy on disk, read once and written as it changes. - static std::string cachePath(); - void loadCache(); - void saveCache(); - void take(const std::string& text, const std::string& error); + void take(const std::string& text, const std::string& error); std::string mText; std::string mError; @@ -100,15 +88,7 @@ class ALScriptStudioVimrc final : public LLSingleton // changes (ALWatchedFile, off the main thread). std::unique_ptr mWatch; LLTimer mSinceCheck; - // The notecard's copy kept on disk: whose, from which asset, its name - // and its text. - bool mCacheLoaded = false; - LLUUID mCacheItem; - LLUUID mCacheAsset; - std::string mCacheName; - std::string mCacheText; - // The asset a fetch is on its way for, so that another is not asked. - LLUUID mFetching; - boost::signals2::scoped_connection mSettingChanged; + std::unique_ptr mNotecard; + boost::signals2::scoped_connection mNotecardChanged; changed_signal_t mChanged; }; diff --git a/indra/newview/alscriptweightspane.cpp b/indra/newview/alscriptweightspane.cpp index ed3fec8637..01fd885f60 100644 --- a/indra/newview/alscriptweightspane.cpp +++ b/indra/newview/alscriptweightspane.cpp @@ -294,6 +294,13 @@ void ALScriptWeightsPane::fillParts() { head += " " + mStrings->getString("WeightsHeadError", args); } + // A compiler that records no lines -- LSL's for Luau -- leaves the + // heat beside the text with nothing to show: said, rather than left to + // look like a script whose lines weigh nothing. + if (weight->target == ALScriptWeight::Target::LSLLuau && weight->compiled && weight->total > 0 && weight->lines.empty()) + { + head += " " + mStrings->getString("WeightsHeadNoLines", args); + } if (mShown.beforeOptimizer) { const bool own = !mShown.weights.empty() && weight->target == mShown.weights.front().target; @@ -307,15 +314,33 @@ void ALScriptWeightsPane::fillParts() head += " " + mStrings->getString("WeightsHeadBefore"); } } - if (mShown.region) + if (mShown.region && mShown.region->hasMemory()) { args["[RESERVED]"] = llformat("%f", (F64)mShown.region->memory / 1024.0); args["[URLS]"] = std::to_string(mShown.region->urls); args["[WHEN]"] = ALRecoveryEntry::sayWhen(mShown.region->when); head += " " + mStrings->getString(mShown.region->urls > 0 ? "WeightsHeadRegionUrls" : "WeightsHeadRegion", args); } + // And the time its scripts take, where the region tells an estate + // manager. + if (mShown.region && mShown.region->hasTime()) + { + args["[TIME]"] = llformat("%.3f", mShown.region->time); + args["[WHEN]"] = ALRecoveryEntry::sayWhen(mShown.region->timeWhen); + head += " " + mStrings->getString("WeightsHeadRegionTime", args); + } mHead->setText(head); - mHead->setToolTip(mStrings->getString(mShown.region ? "WeightsHeadRegionTip" : "WeightsHeadTip")); + // The tip says how each of what the head says is counted. + std::string tip = mStrings->getString("WeightsHeadTip"); + if (mShown.region && mShown.region->hasMemory()) + { + tip += " " + mStrings->getString("WeightsHeadRegionTip"); + } + if (mShown.region && mShown.region->hasTime()) + { + tip += " " + mStrings->getString("WeightsHeadRegionTimeTip"); + } + mHead->setToolTip(tip); // Each part, with how it moved since the text was last saved, where it // was weighed for this target then. diff --git a/indra/newview/alscriptworkspace.cpp b/indra/newview/alscriptworkspace.cpp index d56e4bf581..760eba0cbd 100644 --- a/indra/newview/alscriptworkspace.cpp +++ b/indra/newview/alscriptworkspace.cpp @@ -36,6 +36,7 @@ #include "alscripttempfiles.h" #include "lldbstrings.h" #include "llagent.h" +#include "llavatarnamecache.h" #include "llcorehttputil.h" #include "llappviewer.h" #include "llassetstorage.h" @@ -45,6 +46,8 @@ #include "lleventtimer.h" #include "llfilesystem.h" #include "llfloaterperms.h" +#include "llfloaterreg.h" +#include "llfloatertopobjects.h" #include "llinventory.h" #include "llinventorydefines.h" #include "llinventorymodel.h" @@ -74,6 +77,8 @@ #include +#include + #include #include #include @@ -266,6 +271,25 @@ namespace } // What the region reserves, asked of the viewer's world. + // Top Scripts asked for by the studio, oldest first, by owner, each + // given up once its answer has had time to come. The region's answer, + // LandStatReply, says neither who asked nor what for: which of these an + // answer of top scripts by owner is for, if any, is + // ALScriptRegionUsage::answering's to say, and any other answer is the + // Top Objects floater's, as before. + struct TimeAsked + { + F64 until = 0.0; + std::string owner; + std::function told; + }; + std::deque& timesAsked() + { + static std::deque asked; + return asked; + } + constexpr F64 TIME_ANSWER_WITHIN = 30.0; + ALScriptRegionUsage::World regionUsageWorld() { typedef ALScriptRegionUsage::World World; @@ -325,6 +349,41 @@ namespace }); }); }; + world.mayAskTime = []() { return gAgent.canManageEstate(); }; + world.ownerOf = [](const LLUUID& root) -> std::string { + // By the name Top Scripts gives an owner, the legacy one: the + // agent's own objects, or another's whose name is in hand. A + // group's, or one not known yet, is not asked about. + const LLViewerObject* object = gObjectList.findObject(root); + // A group's: the agent may act as its owner, and its owner id is + // the group's, which no avatar name answers for. + if (!object || object->permGroupOwner()) + { + return std::string(); + } + const LLUUID owner = object->permYouOwner() ? gAgentID : object->mOwnerID; + LLAvatarName name; + return owner.notNull() && LLAvatarNameCache::get(owner, &name) ? name.getLegacyName() : std::string(); + }; + world.askTime = [](const std::string& owner, std::function told) { + LLViewerRegion* region = gAgent.getRegion(); + if (!region || !gMessageSystem) + { + return; + } + timesAsked().push_back({ LLTimer::getTotalSeconds() + TIME_ANSWER_WITHIN, owner, std::move(told) }); + LLMessageSystem* msg = gMessageSystem; + msg->newMessageFast(_PREHASH_LandStatRequest); + msg->nextBlockFast(_PREHASH_AgentData); + msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID()); + msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID()); + msg->nextBlockFast(_PREHASH_RequestData); + msg->addU32Fast(_PREHASH_ReportType, STAT_REPORT_TOP_SCRIPTS); + msg->addU32Fast(_PREHASH_RequestFlags, STAT_FILTER_BY_OWNER); + msg->addStringFast(_PREHASH_Filter, owner); + msg->addS32Fast(_PREHASH_ParcelLocalID, 0); + msg->sendReliable(region->getHost()); + }; return world; } } @@ -986,7 +1045,7 @@ bool ALScriptWorkspace::uploadNotecard(const ALScriptRef& ref, const std::string void ALScriptWorkspace::prepare(const ALScriptRef& ref, const std::string& name, const LLUUID& asset_id, const std::string& text, bool lua, const std::string& target, prepared_callback_t callback, bool anyway) { - if (!ALScriptEnvelope::looksWrapped(text) && !ALScriptPreprocessor::enabled()) + if (ALPreprocessor::wanted(text, lua, ALScriptEnvelope::looksWrapped(text), ALScriptPreprocessor::enabled()) == ALPreprocessor::Wanted::No) { ALScriptPrepared as_is; as_is.text = text; @@ -1758,6 +1817,69 @@ bool ALScriptWorkspace::askRunning(const ALScriptRef& ref) } // static +void ALScriptWorkspace::processLandStatReply(LLMessageSystem* msg, void** data) +{ + // Those given up on gone; then an answer of top scripts by owner is the + // studio's where answering says whose, and anything else Top Objects'. + std::deque& asked = timesAsked(); + const F64 now = LLTimer::getTotalSeconds(); + while (!asked.empty() && asked.front().until < now) + { + asked.pop_front(); + } + U32 report = 0; + U32 flags = 0; + msg->getU32Fast(_PREHASH_RequestData, _PREHASH_ReportType, report); + msg->getU32Fast(_PREHASH_RequestData, _PREHASH_RequestFlags, flags); + if (asked.empty() || report != STAT_REPORT_TOP_SCRIPTS || (flags & STAT_FILTER_BY_OWNER) == 0) + { + LLFloaterTopObjects::handle_land_reply(msg, data); + return; + } + ALScriptRegionUsage::times_t times; + std::vector named; + const S32 count = msg->getNumberOfBlocksFast(_PREHASH_ReportData); + for (S32 block = 0; block < count; ++block) + { + LLUUID task; + F32 score = 0.f; + std::string owner; + msg->getUUIDFast(_PREHASH_ReportData, _PREHASH_TaskID, task, block); + msg->getF32Fast(_PREHASH_ReportData, _PREHASH_Score, score, block); + msg->getStringFast(_PREHASH_ReportData, _PREHASH_OwnerName, owner, block); + named.push_back(std::move(owner)); + if (task.notNull()) + { + times[task] = score; + } + } + std::vector waiting; + for (const TimeAsked& one : asked) + { + waiting.push_back(one.owner); + } + // Top Objects takes an answer only while it is open, and what it waits + // on says which may be its. + const LLFloaterTopObjects* top = LLFloaterReg::findTypedInstance("top_objects"); + std::optional theirs; + if (std::string owner; top && top->isInVisibleChain() && top->waitsForOwner(owner)) + { + theirs = owner; + } + const std::optional whose = ALScriptRegionUsage::answering(waiting, named, theirs); + if (!whose) + { + LLFloaterTopObjects::handle_land_reply(msg, data); + return; + } + const auto told = std::move(asked[*whose].told); + asked.erase(asked.begin() + static_cast(*whose)); + if (told) + { + told(times); + } +} + void ALScriptWorkspace::processScriptRunningReply(LLMessageSystem* msg, void** data) { ALScriptRunningState state; diff --git a/indra/newview/alscriptworkspace.h b/indra/newview/alscriptworkspace.h index 430b0e6488..ae4a8e500c 100644 --- a/indra/newview/alscriptworkspace.h +++ b/indra/newview/alscriptworkspace.h @@ -177,6 +177,10 @@ class ALScriptWorkspace : public LLSingleton // The region's answer, registered for the message; the legacy live // editor hears it through here. static void processScriptRunningReply(LLMessageSystem* msg, void** data); + // The region's answer to Top Scripts and Top Colliders, registered for + // the message: the studio's where it asked for its objects' time + // (ALScriptRegionUsage), else the Top Objects floater's. + static void processLandStatReply(LLMessageSystem* msg, void** data); // The experience a script in an object runs under, asked of its // region: the null one for none -- and for a script in the inventory, diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index 6db4e08bd4..d77039277a 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -313,7 +313,7 @@ ALScriptPreprocEnabled Comment - Script Studio runs the preprocessor over every script it saves, uploading the expanded code with the source kept in Firestorm's envelope. A script that came wrapped is preprocessed on saving whatever this says. + Script Studio runs the preprocessor over every script it saves, uploading the expanded code with the source kept in Firestorm's envelope. A script that came wrapped, or that has a directive of the preprocessor's, is preprocessed on saving whatever this says. Persist 1 Type diff --git a/indra/newview/app_settings/settings_per_account_alchemy.xml b/indra/newview/app_settings/settings_per_account_alchemy.xml index abbff9878c..3dfc424e78 100644 --- a/indra/newview/app_settings/settings_per_account_alchemy.xml +++ b/indra/newview/app_settings/settings_per_account_alchemy.xml @@ -121,6 +121,17 @@ Value 0 + ALScriptStudioSnippetsNotecard + + Comment + The snippet notecard in inventory that Script Studio follows, by its item; empty for none. Its snippets are offered beside the viewer's and your own as it stands; its text is kept on disk with the asset it was read from, and fetched again only when the notecard has a new one. + Persist + 1 + Type + String + Value + + ALScriptStudioVimrcNotecard Comment diff --git a/indra/newview/app_settings/snippets/slua.xml b/indra/newview/app_settings/snippets/slua.xml index a39cb241b7..9db891ddb8 100644 --- a/indra/newview/app_settings/snippets/slua.xml +++ b/indra/newview/app_settings/snippets/slua.xml @@ -96,7 +96,7 @@ end detail A loop over a table body - for ${1:key}, ${2:value} in ${3:table} do + for ${1:k}, ${2:v} in ${3:table} do $0 end diff --git a/indra/newview/llcompilequeue.cpp b/indra/newview/llcompilequeue.cpp index 9b35ebc21d..93a4b7d1fa 100644 --- a/indra/newview/llcompilequeue.cpp +++ b/indra/newview/llcompilequeue.cpp @@ -513,7 +513,8 @@ bool LLFloaterCompileQueue::processScript(LLHandle hfloat } else { - if (ALScriptEnvelope::looksWrapped(text) || ALScriptPreprocessor::enabled()) + if (ALPreprocessor::wanted(text, script_is_lua, ALScriptEnvelope::looksWrapped(text), ALScriptPreprocessor::enabled()) != + ALPreprocessor::Wanted::No) { auto prepared = std::make_shared(); ALScriptWorkspace::instance().prepare(ref, inventory->getName(), assetId, text, script_is_lua, compile_target, diff --git a/indra/newview/llfloatertopobjects.cpp b/indra/newview/llfloatertopobjects.cpp index 3971b42316..56957f1c13 100644 --- a/indra/newview/llfloatertopobjects.cpp +++ b/indra/newview/llfloatertopobjects.cpp @@ -129,7 +129,9 @@ void LLFloaterTopObjects::setMode(U32 mode) // static void LLFloaterTopObjects::handle_land_reply(LLMessageSystem* msg, void** data) { - LLFloaterTopObjects* instance = LLFloaterReg::getTypedInstance("top_objects"); + // Found, not built: an answer can come while neither window is open + // (one of Script Studio's asks, given up on before it came). + LLFloaterTopObjects* instance = LLFloaterReg::findTypedInstance("top_objects"); if(instance && instance->isInVisibleChain()) { instance->handleReply(msg, data); @@ -143,7 +145,7 @@ void LLFloaterTopObjects::handle_land_reply(LLMessageSystem* msg, void** data) } else { - LLFloaterRegionInfo* region_info_floater = LLFloaterReg::getTypedInstance("region_info"); + LLFloaterRegionInfo* region_info_floater = LLFloaterReg::findTypedInstance("region_info"); if(region_info_floater) { region_info_floater->enableTopButtons(); @@ -152,8 +154,19 @@ void LLFloaterTopObjects::handle_land_reply(LLMessageSystem* msg, void** data) } +bool LLFloaterTopObjects::waitsForOwner(std::string& owner) const +{ + if (!mWaiting || mAskedMode != STAT_REPORT_TOP_SCRIPTS || !(mAskedFlags & STAT_FILTER_BY_OWNER)) + { + return false; + } + owner = mAskedFilter; + return true; +} + void LLFloaterTopObjects::handleReply(LLMessageSystem *msg, void** data) { + mWaiting = false; U32 request_flags; U32 total_count; U64 total_memory = 0; @@ -457,6 +470,11 @@ void LLFloaterTopObjects::onRefresh() filter = mFilter; clearList(); + mWaiting = true; + mAskedMode = mode; + mAskedFlags = flags; + mAskedFilter = filter; + LLMessageSystem *msg = gMessageSystem; msg->newMessageFast(_PREHASH_LandStatRequest); msg->nextBlockFast(_PREHASH_AgentData); diff --git a/indra/newview/llfloatertopobjects.h b/indra/newview/llfloatertopobjects.h index beb1ca1b9c..f312115d46 100644 --- a/indra/newview/llfloatertopobjects.h +++ b/indra/newview/llfloatertopobjects.h @@ -72,6 +72,11 @@ class LLFloaterTopObjects final : public LLFloater static void setMode(U32 mode); void disableRefreshBtn(); + // Whether it waits on an answer of top scripts filtered by owner, and + // whose: the region's answer says neither who asked nor what for, and + // Script Studio asks the same (ALScriptWorkspace::processLandStatReply). + bool waitsForOwner(std::string& owner) const; + private: LLFloaterTopObjects(const LLSD& key); ~LLFloaterTopObjects(); @@ -114,6 +119,12 @@ class LLFloaterTopObjects final : public LLFloater U32 mFlags; std::string mFilter; + // What was last asked, until its answer comes. + bool mWaiting = false; + U32 mAskedMode = 0; + U32 mAskedFlags = 0; + std::string mAskedFilter; + bool mInitialized; F32 mtotalScore; diff --git a/indra/newview/llscripteditorws.cpp b/indra/newview/llscripteditorws.cpp index abe32564e4..1ce0b23af3 100644 --- a/indra/newview/llscripteditorws.cpp +++ b/indra/newview/llscripteditorws.cpp @@ -1896,8 +1896,9 @@ LLSD LLScriptEditorWSServer::saveScript(LLViewerObject* prim, LLInventoryItem* i if (region_of(prim)->getCapability("UpdateScriptTask").empty()) throw LLJSONRPCConnection::InternalError("UpdateScriptTask capability not available"); - // Sent as the studio sends it: expanded afresh where the preprocessor - // is on, or where the script is in its envelope, so that its includes + // Sent as the studio sends it: expanded afresh where a save runs the + // preprocessor (ALPreprocessor::wanted) -- it is on, the script is in + // its envelope, or it has a directive -- so that its includes // are current -- sent whatever the preprocessor found, since a save is // someone's work, with what it found said. But an envelope whose // compiled half was edited here, its source as it was, goes up as it @@ -1912,7 +1913,7 @@ LLSD LLScriptEditorWSServer::saveScript(LLViewerObject* prim, LLInventoryItem* i const std::optional held = ALScriptEnvelope::parse(fetch_item_asset(prim, item, LLAssetType::AT_LSL_TEXT)); as_is = held && held->source == was->source && held->expanded != was->expanded; } - if (!as_is && (was || ALScriptPreprocessor::enabled())) + if (!as_is && ALPreprocessor::wanted(content, lua, was.has_value(), ALScriptPreprocessor::enabled()) != ALPreprocessor::Wanted::No) { auto prepared = std::make_shared(); await_async_result( diff --git a/indra/newview/llstartup.cpp b/indra/newview/llstartup.cpp index af710f33be..c0c17db063 100644 --- a/indra/newview/llstartup.cpp +++ b/indra/newview/llstartup.cpp @@ -3237,7 +3237,7 @@ void register_viewer_callbacks(LLMessageSystem* msg) msg->setHandlerFuncFast(_PREHASH_ParcelObjectOwnersReply, LLPanelLandObjects::processParcelObjectOwnersReply); msg->setHandlerFuncFast(_PREHASH_InitiateDownload, process_initiate_download); - msg->setHandlerFuncFast(_PREHASH_LandStatReply, LLFloaterTopObjects::handle_land_reply); + msg->setHandlerFuncFast(_PREHASH_LandStatReply, ALScriptWorkspace::processLandStatReply); msg->setHandlerFuncFast(_PREHASH_GenericMessage, process_generic_message); msg->setHandlerFuncFast(_PREHASH_GenericStreamingMessage, process_generic_streaming_message); msg->setHandlerFuncFast(_PREHASH_LargeGenericMessage, process_large_generic_message); diff --git a/indra/newview/skins/default/xui/en/floater_script_studio.xml b/indra/newview/skins/default/xui/en/floater_script_studio.xml index b7c2cd7607..3adfece27f 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio.xml @@ -382,6 +382,11 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is Showing the source. Click for the Preprocessed view, the code the server compiles Showing the preprocessed code the server compiles; read-only. Click for the source ([KEYS]) Showing the preprocessed code the server compiles; read-only. Click for the source + A save preprocesses it: it was saved with the preprocessor before, and keeps its source. + A save preprocesses it: it has directives, which the server could compile no other way. + A save preprocesses it: Build > Preprocess on Save is on. + Not preprocessed on save + A save sends this script as written: it has no directive, was not saved with the preprocessor before, and Build > Preprocess on Save is off. The Preprocessed view shows what the preprocessor would make of it, not what goes up. declaration Where [NAME] is declared No symbol names match what you typed. @@ -469,6 +474,8 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is Renamed to [NAME] [SCRIPT] is not open here, or has changed since the lookup; its occurrences are listed in References. [FIXES] for [NAME], shown beside the text. Apply makes them, as one step to undo. + Nothing previewed is waiting to be applied. Ask for the fixes again to see them. + The fixes were not made: the text would not take them. The text changed after the preview, so its fixes were not made. Use Fix All again to see them for the text now. Apply Make the fixes previewed in [NAME], as one step to undo @@ -622,6 +629,7 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is require("[NAME]") is saved as written, but the grid does not support require: turn on Build > Preprocess on Save to include the module ([WORD] is an extension: turn on Build > Preprocess on Save and Build > Extensions) ([WORD] declares a constant, and lets a global be computed from other globals: turn on Build > Preprocess on Save) + (lines starting with # are the preprocessor's: turn on Build > Preprocess on Save) ([WORD] marks a function to be inlined at each call: turn on Build > Preprocess on Save) Clear Menu Compiler @@ -669,13 +677,17 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is [NAME] on [TARGET]: about [SIZE,number,1] of [LIMIT] KB used, [SHARE]. [NAME] does not compile for [TARGET]: [ERROR] Currently, [ERROR]. + Its lines are not weighed one by one: the compiler for [TARGET] records no lines. Measured before the preprocessor's optimizer, which runs on save. Measured before the preprocessor's optimizer. As saved, it is [SENT,number,1] KB. Code size only, not counting the lists and strings the script builds while it runs. In the region, its object reserves [RESERVED,number,0] KB for all its scripts, as of [WHEN]. In the region, its object reserves [RESERVED,number,0] KB and [URLS] URLs for all its scripts, as of [WHEN]. - Code size only, not counting the lists and strings the script builds while it runs. The region counts the memory of every script in the object, each at its limit (64 KB for Mono unless llSetMemoryLimit lowers it), not what it uses. + The region counts the memory of every script in the object, each at its limit (64 KB for Mono unless llSetMemoryLimit lowers it), not what it uses. [RESERVED,number,0] KB reserved + [TIME] ms a frame + Its scripts take [TIME] ms of the region's time a frame, as of [WHEN]. + The time is the region's own count for all the object's scripts, on average, as its Top Scripts list shows an estate manager. [TARGET]: [BYTES] bytes of code, of [MAX] bytes of memory. [TARGET]: about [BYTES] bytes of code, of [MAX] bytes of memory, estimated from the compiled code. Does not compile for [TARGET]: [ERROR] @@ -974,6 +986,15 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is + + + + + + + + + Script snippets the notecard could not be made Only a notecard in your inventory can bring snippets in + Only a notecard in your inventory can be followed for snippets + From [NAME], the notecard you follow. Copy it to make a version of your own, or change it in the notecard. + (a notecard not in inventory yet) + Drop a snippets notecard here to follow it: its snippets are offered beside yours, as the notecard stands whoever changes it. Cached until it changes A declaration that hides one in an outer scope An assignment used as a condition Changing to the current state @@ -103,7 +107,9 @@ A list, a string or ll counted from 0, as LSL counted (the studio's own) llcompat's function where ll's means the same (the studio's own) A boolean compared with a number, as LSL's TRUE and FALSE were (the studio's own) - A global made where a local would do, or a function made global inside a block (the studio's own) + A global given a value at the top where a local would do, or a function made global inside a block (the studio's own) + A for loop's own variable set inside it, which Luau's for does not go on from as LSL's did (the studio's own) + A function at the top made global where a local function would do (the studio's own) A condition in brackets, as LSL's had to be (the studio's own) a = b and then b = a, which is no swap (the studio's own) A call that only answers, its answer thrown away (the studio's own) @@ -970,7 +976,7 @@ end layout="topleft" left="412" name="preproc_enabled" - tool_tip="Preprocess all scripts on save, keeping the source in Firestorm's envelope. Off: only enveloped ones" + tool_tip="Preprocess every script on save, keeping the source in Firestorm's envelope. Off: only scripts that have directives, or were saved with the preprocessor before" top_pad="6" width="370" /> left="12" multi_select="false" name="snippet_list" - tool_tip="Your snippets, then the viewer's built-in ones (copy one to change it). Drop a snippets notecard here to add its snippets to yours." + tool_tip="Your snippets, then the followed notecard's, then the viewer's built-in ones (copy one to change it). Drop a snippets notecard here to add its snippets to yours." top_pad="8" width="280"> @@ -1303,6 +1309,38 @@ end top_pad="4" width="140" /> + + Follow notecard: + + +